Conversation with Gemini
// src/components/CameraCapture.tsx
import { useState, type ChangeEvent } from 'react'
import './CameraCapture.css'
import { type RealFoodAnalysis } from '../types' // Import new component
import AnalysisResults from './AnalysisResult' // Import the new results component
export default function CameraCapture() {
const [image, setImage] = useState<string | null>(null)
const [realFoodAnalysis, setRealFoodAnalysis] = useState<RealFoodAnalysis | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string>("")
const handleImageChange = async (e: ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files[0]) {
const file = e.target.files[0]
const url = URL.createObjectURL(file)
setImage(url)
// Reset states
setRealFoodAnalysis(null)
setError("")
setLoading(true)
const formData = new FormData()
formData.append("file", file)
try {
// ⚠️ Make sure this IP matches your computer!
const response = await fetch("https://10.189.4.149:8000/api/analyze", {
method: "POST",
body: formData,
})
const data = await response.json()
// Parse the JSON string from Backboard
let parsedData;
try {
parsedData = typeof data.health_analysis === 'string'
? JSON.parse(data.health_analysis)
: data.health_analysis;
} catch (e) {
parsedData = data.health_analysis;
}
if (parsedData) {
setRealFoodAnalysis(parsedData)
} else {
setError("Could not read ingredients format.")
}
} catch (err) {
setError("Error: Could not reach the brain! 🧠❌")
} finally {
setLoading(false)
}
}
}
const hasResults = realFoodAnalysis !== null || loading; // Keep results screen open if loading
return (
<div className="camera-container">
{/* THE SLIDING WRAPPER */}
<div className={`sliding-wrapper ${hasResults ? 'show-results' : 'show-camera'}`}>
{/* LEFT SIDE: Camera */}
<div className="image-area">
{image ? (
<img src={image} alt="Captured" className="photo-preview" />
) : (
<div className="placeholder">
<div style={{fontSize: '3rem', marginBottom: '10px'}}>📸</div>
<p>Take a photo</p>
</div>
)}
</div>
{/* RIGHT SIDE: The New Results Component */}
<AnalysisResults
data={realFoodAnalysis}
loading={loading}
error={error}
/>
</div>
{/* BUTTON (Always visible) */}
<div className="controls-area">
<input
type="file"
accept="image/*"
capture="environment"
onChange={handleImageChange}
id="cameraInput"
style={{ display: 'none' }}
/>
<label htmlFor="cameraInput" className="camera-button">
{loading ? "Scanning..." : (realFoodAnalysis ? "📸 Scan New" : "📸 Take Photo")}
</label>
</div>
</div>
)
}
from fastapi import FastAPI, UploadFile, File
from fastapi.middleware.cors import CORSMiddleware
import google.generativeai as genai
from backboard import BackboardClient # Use the SDK!
from PIL import Image
import io
import asyncio # For startup logic
# --- CONFIGURATION ---
GOOGLE_API_KEY = "AIzaSyDR7hHR3Dqh125ZVSSWainMGB5n5oGbhtE"
BACKBOARD_API_KEY = "espr_oz9ZcjRqXboq5eJ7uQrUD9AM3kSpxr7elV80GBJdT6E"
# 1. Setup Gemini ("The Eyes")
genai.configure(api_key=GOOGLE_API_KEY)
vision_model = genai.GenerativeModel('gemini-2.5-flash')
# 2. Setup Backboard ("The Brain")
bb_client = BackboardClient(api_key=BACKBOARD_API_KEY)
# Global variables to store our AI Agent IDs
assistant_id = None
thread_id = None
app = FastAPI()
# Allow React to talk to us
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
# --- STARTUP EVENT ---
# This runs once when the server starts to set up the Backboard Agent
@app.on_event("startup")
async def startup_event():
global assistant_id, thread_id
print("🤖 Initializing AI Agent...")
# Print out ListModels
# models = genai.list_models()
# print(f"Available models: {[model.name for model in models]}") # Debug print
# Create the Health Expert
assistant = await bb_client.create_assistant(
name="Health Analyzer",
system_prompt="""
SYSTEM / ROLE
You are “Real Food Score” evaluator for a consumer ingredient-scanning app. Your job is to score how “real-food / minimally processed” a packaged product is using ONLY the ingredient list provided (no nutrition facts unless explicitly included). Be conservative, avoid medical claims, and keep decisions explainable and deterministic.
GOAL
Given an ingredient list, return:
1) A Real Food Score from 0–100 (higher = more minimally processed / closer to whole foods)
2) Values to populate a two-part horizontal bar: Supportive vs Risk (POS vs NEG points)
3) “Top reasons” bullets (✅ positives + ⚠️ concerns) that mention specific ingredients
4) A full ingredient categorization into 4 groups:
- positive_for_lens (supports “real food / low-processed”)
- negative_for_lens (signals high processing / ultra-processed formulation)
- mixed_for_lens (context-dependent or umbrella terms)
- neutral_for_lens (neither clearly signals processing nor whole-food-ness)
5) A list of “Lab Labels”: ingredients that the general public may not recognize, with plain-English explanations of what they are and why they’re added
6) Credible sources consulted (org + document family) and brief “how used”
7) Uncertainty notes if ingredient list is incomplete/ambiguous
SAFETY + QUALITY RULES (NON-NEGOTIABLE)
- Do NOT claim an ingredient “causes” disease or conditions.
- Additives can be “permitted/regulated” yet still be markers of processing complexity. Frame accordingly.
- Never invent ingredients. Only use what appears in the list.
- If an ingredient is ambiguous (e.g., “spices,” “natural flavors”), treat as a processing signal with uncertainty.
- Deterministic: same ingredient list → same score.
LENS DEFINITION (REAL FOOD / LOW-PROCESSED)
This lens estimates how close the product is to minimally processed whole foods vs “ultra-processed” formulations.
More whole-food ingredients + fewer industrial additives + simpler formulation → higher score.
CREDIBLE FOUNDATIONAL REFERENCES (USE THESE CONCEPTS)
Use these as conceptual anchors; do not over-quote:
- NOVA food processing framework (markers of ultra-processed foods: flavors, colors, emulsifiers, sweeteners, reconstituted ingredients)
- Health Canada – Lists of Permitted Food Additives (regulatory framing; avoid “unsafe” claims)
- FDA – Food additives/GRAS overview (regulatory framing; avoid overstating risk)
- EFSA – Food additives topic pages (regulatory/scientific framing)
SCORING MODEL (WEIGHTED, EXPLAINABLE)
Start BASE_SCORE = 100.
Compute PENALTIES (processing markers) and BONUSES (whole-food/minimal formulation signals).
Final score = clamp( BASE_SCORE - PENALTY_POINTS + BONUS_POINTS , 0, 100 ).
Compute for bar visualization:
- POS_POINTS = BONUS_POINTS
- NEG_POINTS = PENALTY_POINTS
CRITERIA + WEIGHTS
Apply exactly these rules.
A) PENALTIES (processing / ultra-processed markers)
P1. Artificial colors / color additives present (12)
Trigger: “Red 40”, “Yellow 5”, “Blue 1”, “FD&C”, “color added”, “artificial color”
P2. Non-nutritive sweeteners present (10)
Trigger: aspartame, sucralose, acesulfame K, saccharin, neotame, advantame
Note: stevia/monk fruit penalty = 6 (still a processing marker)
P3. Added sugar indicators (up to 18 by prominence)
Identify sugar terms: sugar, cane sugar, corn syrup, HFCS, dextrose, glucose, fructose, maltose, invert sugar, honey, agave, molasses, syrup, fruit juice concentrate (when used as sweetener)
- If sugar term is ingredient #1 or #2: 18
- Else if 2+ distinct sugar terms: 12
- Else if 1 sugar term later: 6
P4. “Flavors” marker (8)
Trigger: “natural flavors”, “artificial flavors”, “flavoring”
P5. Emulsifiers / texture agents stack (0–12 by count)
Examples: polysorbate 80, mono- & diglycerides, lecithin, carrageenan, xanthan gum, guar gum, cellulose gum, DATEM, modified cellulose
- 1–2: 4
- 3–4: 8
- 5+: 12
P6. Preservatives marker (0–10 by count)
Examples: sodium benzoate, potassium sorbate, calcium propionate, BHA/BHT, TBHQ, sodium nitrite/nitrate
- 1: 5
- 2+: 10
P7. Refined isolates / reconstituted ingredients (0–10)
Examples: maltodextrin, modified starch, protein isolate, isolated fiber, inulin/chicory fiber (context-dependent), “whey protein isolate”
- 1–2: 5
- 3+: 10
P8. Ingredient list complexity (0–10)
- >15 ingredients: 10
- 10–15: 6
- <10: 0
P9. First-ingredient quality penalty (0–10)
- If first ingredient is sugar/syrup OR a refined isolate/starch: 10
- If first ingredient is refined flour or refined oil: 6
- If first ingredient is a whole food: 0
B) BONUSES (whole-food / minimal formulation signals)
B1. Whole-food first ingredient (12)
Trigger: ingredient #1 is a recognizable whole food (oats, milk, peanuts, beans, fruit, etc.)
B2. Short “kitchen-like” list (8)
Trigger: ingredient_count < 10 AND no colors AND no “flavors” AND emulsifiers <= 2 AND preservatives = 0
B3. Whole-food density (0–10)
Estimate proportion of ingredients that are recognizable whole foods vs additives/isolate markers:
- High: +10
- Medium: +6
- Low: +0
Provide short justification.
B4. No sweeteners (6)
Trigger: no added sugar terms AND no non-nutritive sweeteners
B5. Minimal additive stack (4)
Trigger: emulsifiers=0 AND preservatives=0 AND no “flavors”
DETERMINISTIC EVALUATION STEPS
1) Normalize ingredient list (lowercase, strip punctuation) and split into items.
2) Detect and list: sugars + positions, sweeteners, colors, flavors, emulsifiers, preservatives, isolates, ingredient_count, first_ingredient.
3) Apply penalties/bonuses exactly.
4) Compute score, POS_POINTS, NEG_POINTS.
5) Select top 3 penalties + top 3 bonuses as reasons (largest points).
6) Categorize every ingredient into 4 groups:
- positive_for_lens: recognizable whole foods / simple culinary ingredients that support minimal processing
- negative_for_lens: additives/sweeteners/colors/preservatives/isolate markers
- mixed_for_lens: umbrella/ambiguous terms (e.g., “spices”, “natural flavors”, “flavoring”, “enriched flour” sometimes) or context-dependent items (e.g., lecithin, inulin)
- neutral_for_lens: salt, water, basic acids, standard seasonings that don’t strongly signal processing
7) Identify “Lab Labels” deterministically:
- Include ingredients that are likely unfamiliar to the general public, especially:
a) preservatives, emulsifiers, stabilizers, thickeners, anti-caking agents, humectants, acidity regulators, firming agents
b) artificial colors, non-nutritive sweeteners, “natural flavors” / “artificial flavors”
c) refined isolates/reconstituted ingredients (e.g., maltodextrin, modified starch, protein isolate)
d) chemical-sounding names (common suffix cues: -ate, -ite, -ide, -ose, -ol, -ium, -phosphate, -carbonate, -chloride)
- Exclude common kitchen staples unless ambiguous: water, salt, sugar, flour, milk, eggs, butter, olive oil, oats, rice, etc.
- For each lab label, provide:
- ingredient (exact as listed)
- plain_english (what it is, in 1 sentence)
- why_added (its function in foods, in 1 sentence)
- common_in (1–3 example product types; e.g., “soft drinks”, “salad dressings”)
8) Provide sources consulted list (org + document family) with “how used”.
OUTPUT FORMAT (STRICT JSON ONLY)
Return one JSON object (NOTE: JSON portion must use DOUBLE CURLY BRACES):
{{
"lens": "real_food_score",
"score": <int 0-100>,
"bar": {{
"positive_points": <number>,
"negative_points": <number>,
"positive_ratio": <number 0-1>,
"negative_ratio": <number 0-1>
}},
"reasons": {{
"positives": [ "<✅ ...>", "<✅ ...>", "<✅ ...>" ],
"concerns": [ "<⚠️ ...>", "<⚠️ ...>", "<⚠️ ...>" ]
}},
"criteria_hits": [
{{ "id": "P3", "name": "Added sugar indicators", "direction": "negative", "points": <number>, "evidence": "<what triggered it>" }}
],
"ingredients_breakdown": {{
"positive_for_lens": [ "<ingredient>", ... ],
"negative_for_lens": [ "<ingredient>", ... ],
"mixed_for_lens": [ "<ingredient>", ... ],
"neutral_for_lens": [ "<ingredient>", ... ]
}},
"lab_labels": [
{{
"ingredient": "<string>",
"plain_english": "<string>",
"why_added": "<string>",
"common_in": ["<string>", "<string>"]
}}
],
"detected_signals": {{
"first_ingredient": "<string>",
"ingredient_count": <int>,
"sugars": {{ "terms": [..], "positions": [..] }},
"sweeteners": [..],
"colors": [..],
"flavors": [..],
"emulsifiers": [..],
"preservatives": [..],
"isolates": [..]
}},
"sources_consulted": [
{{ "source": "NOVA food processing framework", "how_used": "Defines ultra-processed markers (colors, flavors, emulsifiers, sweeteners, reconstituted ingredients)." }},
{{ "source": "Health Canada – Lists of Permitted Food Additives", "how_used": "Frames additives as regulated/permitted while still being processing markers." }},
{{ "source": "FDA – Food additives/GRAS overview", "how_used": "Avoids unsafe claims; permitted additives can still indicate processing." }},
{{ "source": "EFSA – Food additives topic pages", "how_used": "Reference for additive assessment framing; supports cautious language." }}
],
"notes": [ "<uncertainty/caveats>" ]
}}
USER INPUT
ingredients_text: "<string>"
Now evaluate the provided ingredients_text and return STRICT JSON ONLY (using the DOUBLE CURLY BRACES structure above).
"""
)
assistant_id = assistant.assistant_id
# Create a conversation thread
thread = await bb_client.create_thread(assistant_id)
thread_id = thread.thread_id
print("✅ Agent Ready!")
@app.post("/api/analyze")
async def analyze_image(file: UploadFile = File(...)):
# --- STEP 1: READ IMAGE ---
print("📸 Receiving image...")
contents = await file.read()
image = Image.open(io.BytesIO(contents))
# --- STEP 2: GEMINI (EXTRACT TEXT) ---
print("👀 Gemini is reading the label...")
ocr_response = vision_model.generate_content([
"Extract ONLY the list of ingredients and nutrition facts (Only use English, don't add anything else make it simple). Do not add conversational text.",
image
])
extracted_text = ocr_response.text
print(f"📝 Extracted Text: {extracted_text[:50]}...") # Print first 50 chars
# --- STEP 3: BACKBOARD (ANALYZE HEALTH) ---
print("🧠 Backboard is analyzing health...")
# We send the text from Gemini into Backboard
analysis_response = await bb_client.add_message(
thread_id=thread_id,
content=f"Here are the ingredients I found: {extracted_text}. Return ONLY valid JSON.",
stream=False # Wait for the full answer
)
# --- STEP 4: CLEAN THE JSON STRING---
raw_content = analysis_response.content
# Remove markdown code blocks if they exist
clean_json = raw_content.replace("```json", "").replace("```", "").strip()
print(f"cleaned json: {clean_json}") # Debug print to see what is happening
# --- STEP 5: RETURN TO REACT ---
return {
"health_analysis": clean_json
}
The code you shared looks very close to completion! However, there are a few critical mismatches between your Frontend and Backend that will cause it to fail.
Here is the checklist of fixes to make them talk to each other correctly.
1. Fix the Protocol (HTTPS vs HTTP)
Your React code is trying to fetch https://..., but your Python backend is likely running on plain http://.
Fix: Change
https://tohttp://inCameraCapture.tsxunless you have explicitly set up SSL certs on your local machine (which is rare for dev).
2. Update the Backend to Use the Standard SDK
Your backend code imports backboard, but you likely want to use the standard google-genai library we discussed earlier to avoid quota issues and complexity. The backboard library is for a specific alpha tool.
Here is the Optimized Backend that uses the standard Gemini 1.5 Flash model (high quota, fast) and implements your exact "Real Food Score" prompt logic.
New backend/main.py
(Copy this file. It combines your prompt logic with the robust standard client).
from fastapi import FastAPI, UploadFile, File
from fastapi.middleware.cors import CORSMiddleware
from google import genai
from google.genai import types
import os
from dotenv import load_dotenv
# Load environment variables
load_dotenv()
app = FastAPI()
# Enable CORS (Allows React to talk to Python)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Initialize Standard Gemini Client
# Uses the API key from your .env file
client = genai.Client(api_key=os.environ["GEMINI_API_KEY"])
@app.post("/api/analyze")
async def analyze_image(file: UploadFile = File(...)):
print(f"📸 Receiving image: {file.filename}")
# 1. Read the image file
contents = await file.read()
# 2. Prepare the Prompt
# This includes your specific "Real Food Score" rules
prompt_text = """
SYSTEM / ROLE
You are “Real Food Score” evaluator. Your job is to score how “real-food / minimally processed” a product is using ONLY the ingredient list.
GOAL:
Return a JSON object with:
1. Score (0-100)
2. Bar values (positive/negative ratio)
3. Top reasons (positive/concern)
4. Ingredient breakdown (helpful/concerning/mixed/neutral)
5. Lab Labels (definitions for complex ingredients)
CRITERIA SUMMARY:
- Penalties: Artificial colors, non-nutritive sweeteners, added sugars (especially if high up), flavors, emulsifiers, preservatives, refined isolates.
- Bonuses: Whole-food first ingredient, short list (<10 items), high whole-food density.
OUTPUT FORMAT (STRICT JSON ONLY):
Return a single JSON object with these exact keys:
{
"lens": "real_food_score",
"score": <int>,
"bar": { "positive_ratio": <0-1>, "negative_ratio": <0-1> },
"reasons": { "positives": [], "concerns": [] },
"ingredients_breakdown": {
"helpful": [], "concerning": [], "mixed": [], "neutral": []
},
"lab_labels": [
{ "ingredient": "string", "plain_english": "string", "why_added": "string", "common_in": [] }
]
}
DO NOT return Markdown. Return ONLY raw JSON.
"""
print("🧠 Gemini (1.5 Flash) is analyzing...")
try:
# 3. Call Gemini 1.5 Flash
# We send the Image + Prompt in one go (Multimodal)
response = await client.aio.models.generate_content(
model="gemini-1.5-flash",
contents=[
types.Content(
role="user",
parts=[
types.Part.from_bytes(data=contents, mime_type=file.content_type),
types.Part.from_text(text=prompt_text),
],
)
]
)
# 4. Clean and Return JSON
clean_json = response.text.replace("```json", "").replace("```", "").strip()
print(f"✅ AI Response: {clean_json[:100]}...")
return { "health_analysis": clean_json }
except Exception as e:
print(f"❌ Error: {e}")
return { "health_analysis": None, "error": str(e) }
if __name__ == "__main__":
import uvicorn
# ⚠️ 0.0.0.0 is crucial for mobile access
uvicorn.run(app, host="0.0.0.0", port=8000)
3. Update Frontend (CameraCapture.tsx)
Update your fetch URL to use http (not https) and your Ngrok URL (if using Ngrok) or your local IP.
// src/components/CameraCapture.tsx
// ... imports ...
export default function CameraCapture() {
// ... state ...
const handleImageChange = async (e: ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files[0]) {
// ... file setup ...
const formData = new FormData()
formData.append("file", file)
try {
// ⚠️ FIX: Use http:// (not https) and your computer's IP
// Example: "http://192.168.1.15:8000/api/analyze"
// Or if using ngrok: "https://your-ngrok-url.ngrok-free.app/api/analyze"
const BACKEND_URL = "http://10.189.4.149:8000/api/analyze";
const response = await fetch(BACKEND_URL, {
method: "POST",
body: formData,
})
// ... rest of code ...
4. Update Types (types.ts)
Since you added "Lab Labels" to your prompt, update your TypeScript definition so React knows about them.
// src/types.ts
export interface RealFoodAnalysis {
score: number
bar: {
positive_ratio: number
negative_ratio: number
}
reasons: {
positives: string[]
concerns: string[]
}
ingredients_breakdown: {
helpful: string[]
concerning: string[]
mixed: string[]
neutral: string[]
}
// ✅ NEW: Add this section
lab_labels?: {
ingredient: string
plain_english: string
why_added: string
common_in: string[]
}[]
}
How to Run properly
Backend:
python -m uvicorn main:app --reload --host 0.0.0.0 --port 8000Frontend:
npm run dev -- --hostPhone: Access via
http://10.189.4.149:5173(Frontend) -> It will talk tohttp://10.189.4.149:8000(Backend).
{
"lens": "real_food_score",
"score": 36,
"bar": {
"positive_points": 0,
"negative_points": 64,
"positive_ratio": 0.0,
"negative_ratio": 1.0
},
"reasons": {
"positives": [],
"concerns": [
"⚠️ Presence of added sugars: sugar, glucose-fructose.",
"⚠️ Contains artificial flavor, a marker of processing.",
"⚠️ Complexity of ingredient list with 13 ingredients."
]
},
"criteria_hits": [
{
"id": "P3",
"name": "Added sugar indicators",
"direction": "negative",
"points": 12,
"evidence": "sugar, glucose-fructose"
},
{
"id": "P4",
"name": "Flavors marker",
"direction": "negative",
"points": 8,
"evidence": "artificial flavour"
},
{
"id": "P8",
"name": "Ingredient list complexity",
"direction": "negative",
"points": 10,
"evidence": "13 ingredients"
}
],
"ingredients_breakdown": {
"positive_for_lens": [],
"negative_for_lens": [
"sugars (sugar, glucose-fructose)",
"artificial flavour",
"caramel"
],
"mixed_for_lens": [
"niacinamide",
"pyridoxine hydrochloride (vitamin B6)",
"calcium d-pantothenate",
"cyanocobalamin (vitamin b12)",
"taurine",
"riboflavin"
],
"neutral_for_lens": [
"carbonated water",
"citric acid",
"sodium bicarbonate",
"magnesium carbonate",
"caffeine"
]
},
"lab_labels": [
{
"ingredient": "taurine",
"plain_english": "An organic compound often added to energy drinks.",
"why_added": "Used to potentially enhance physical and mental performance.",
"common_in": ["energy drinks"]
},
{
"ingredient": "niacinamide",
"plain_english": "A form of vitamin B3.",
"why_added": "Used as a dietary supplement or fortification for added nutrition.",
"common_in": ["supplements", "fortified foods"]
},
{
"ingredient": "pyridoxine hydrochloride (vitamin B6)",
"plain_english": "A form of vitamin B6.",
"why_added": "Used as a dietary supplement for added nutrition.",
"common_in": ["supplements", "fortified foods"]
},
{
"ingredient": "calcium d-pantothenate",
"plain_english": "A form of vitamin B5.",
"why_added": "Used as a dietary supplement for added nutrition.",
"common_in": ["supplements", "fortified foods"]
},
{
"ingredient": "cyanocobalamin (vitamin B12)",
"plain_english": "A synthetic form of vitamin B12.",
"why_added": "Used as a dietary supplement or fortification for added nutrition.",
"common_in": ["supplements", "fortified foods"]
},
{
"ingredient": "riboflavin",
"plain_english": "Also known as vitamin B2.",
"why_added": "Used for nutritional fortification.",
"common_in": ["fortified cereals", "supplements"]
}
],
"detected_signals": {
"first_ingredient": "carbonated water",
"ingredient_count": 13,
"sugars": {
"terms": ["sugar", "glucose-fructose"],
"positions": [1]
},
"sweeteners": [],
"colors": [],
"flavors": ["artificial flavour"],
"emulsifiers": [],
"preservatives": [],
"isolates": []
},
"sources_consulted": [
{
"source": "NOVA food processing framework",
"how_used": "Defines ultra-processed markers (colors, flavors, emulsifiers, sweeteners, reconstituted ingredients)."
},
{
"source": "Health Canada – Lists of Permitted Food Additives",
"how_used": "Frames additives as regulated/permitted while still being processing markers."
},
{
"source": "FDA – Food additives/GRAS overview",
"how_used": "Avoids unsafe claims; permitted additives can still indicate processing."
},
{
"source": "EFSA – Food additives topic pages",
"how_used": "Reference for additive assessment framing; supports cautious language."
}
],
"notes": []
}
Can you change the type now?
Yes, since your backend is now returning a much more detailed JSON structure (including different keys for the breakdown like positive_for_lens instead of helpful), we need to update src/types.ts to match it exactly.
We also need to update AnalysisResults.tsx because the key names inside ingredients_breakdown have changed.
Step 1: Update src/types.ts
Replace the entire file with this new definition:
// src/types.ts
export interface UserData {
name: string
age: string
height: string
weight: string
diet: 'Normal' | 'Halal' | 'Vegetarian' | 'Vegan'
isPregnant: boolean
}
export interface RealFoodAnalysis {
lens: string
score: number
bar: {
positive_points: number
negative_points: number
positive_ratio: number
negative_ratio: number
}
reasons: {
positives: string[]
concerns: string[]
}
criteria_hits: {
id: string
name: string
direction: string
points: number
evidence: string
}[]
ingredients_breakdown: {
positive_for_lens: string[] // Was "helpful"
negative_for_lens: string[] // Was "concerning"
mixed_for_lens: string[] // Was "mixed"
neutral_for_lens: string[] // Was "neutral"
}
lab_labels: {
ingredient: string
plain_english: string
why_added: string
common_in: string[]
}[]
detected_signals: {
first_ingredient: string
ingredient_count: number
sugars: { terms: string[], positions: number[] }
sweeteners: string[]
colors: string[]
flavors: string[]
emulsifiers: string[]
preservatives: string[]
isolates: string[]
}
sources_consulted: { source: string; how_used: string }[]
notes: string[]
}
Step 2: Update src/components/AnalysisResults.tsx
Since the keys changed (e.g., from helpful to positive_for_lens), your UI will break unless we update how we read the data.
Replace your component with this updated version:
import './AnalysisResults.css'
import { RealFoodAnalysis } from '../types'
interface Props {
data: RealFoodAnalysis | null
loading: boolean
error: string
}
export default function AnalysisResults({ data, loading, error }: Props) {
// Helper for Score Color
const getScoreColor = (score: number) => {
if (score >= 80) return '#2ecc71'; // Green
if (score >= 50) return '#f1c40f'; // Yellow
return '#e74c3c'; // Red
}
// Loading State
if (loading) return (
<div className="results-container loading">
<div className="spinner">🧠</div>
<p>Analyzing Real Food Score...</p>
</div>
)
// Error State
if (error) return <div className="results-container error"><p>⚠️ {error}</p></div>
// Empty State
if (!data) return null;
const { score, bar, reasons, ingredients_breakdown, lab_labels } = data;
return (
<div className="results-container">
{/* 1. BIG SCORE CARD */}
<div className="score-card">
<h3 className="card-title">Real Food Score</h3>
<div className="score-circle" style={{ borderColor: getScoreColor(score) }}>
<span className="score-number" style={{ color: getScoreColor(score) }}>
{score}
</span>
<span className="score-label">/ 100</span>
</div>
</div>
{/* 2. THE VISUAL BAR */}
<div className="bar-section">
<div className="bar-labels">
<span style={{color: '#2ecc71'}}>Real Food</span>
<span style={{color: '#e74c3c'}}>Processed</span>
</div>
<div className="progress-bar-container">
{/* Green Bar */}
<div
className="bar-segment positive"
style={{ flex: bar.positive_ratio || 0.05 }} // Minimum width so it's visible
/>
{/* Red Bar */}
<div
className="bar-segment negative"
style={{ flex: bar.negative_ratio || 0.05 }}
/>
</div>
</div>
{/* 3. TOP REASONS */}
<div className="reasons-section">
{reasons.positives.length > 0 && (
<div className="reason-box good">
<h4>✅ Why it's good</h4>
<ul>
{reasons.positives.map((r, i) => <li key={i}>{r}</li>)}
</ul>
</div>
)}
{reasons.concerns.length > 0 && (
<div className="reason-box bad">
<h4>⚠️ Concerns</h4>
<ul>
{reasons.concerns.map((r, i) => <li key={i}>{r}</li>)}
</ul>
</div>
)}
</div>
{/* 4. INGREDIENT BREAKDOWN */}
<h3 className="section-header">Ingredient Breakdown</h3>
{/* UPDATED KEYS: Using 'negative_for_lens', 'positive_for_lens' etc. */}
<IngredientDropdown
title="⚠️ Concerning Additives"
items={ingredients_breakdown.negative_for_lens}
color="red"
isOpen={true}
/>
<IngredientDropdown
title="🥗 Whole Foods / Helpful"
items={ingredients_breakdown.positive_for_lens}
color="green"
/>
<IngredientDropdown
title="🤷 Mixed / Complex"
items={ingredients_breakdown.mixed_for_lens}
color="orange"
/>
<IngredientDropdown
title="🧂 Neutral"
items={ingredients_breakdown.neutral_for_lens}
color="grey"
/>
{/* 5. NEW: LAB LABELS (Definitions) */}
{lab_labels && lab_labels.length > 0 && (
<>
<h3 className="section-header">📚 Lab Labels</h3>
<div className="lab-labels-list">
{lab_labels.map((label, i) => (
<div key={i} className="lab-card">
<div className="lab-name">{label.ingredient}</div>
<div className="lab-desc">{label.plain_english}</div>
<div className="lab-meta">Example: {label.common_in.join(", ")}</div>
</div>
))}
</div>
</>
)}
</div>
)
}
// Mini Component for the Dropdowns
function IngredientDropdown({ title, items, color, isOpen = false }: any) {
if (!items || items.length === 0) return null;
return (
<details className={`ing-dropdown ${color}`} open={isOpen}>
<summary>
{title}
<span className="count-badge">{items.length}</span>
</summary>
<div className="dropdown-content">
{items.map((item: string, i: number) => (
<div key={i} className="ing-chip">{item}</div>
))}
</div>
</details>
)
}
Step 3: Add CSS for "Lab Labels" (Optional)
I added a new section for "Lab Labels" in the component above. Add this to AnalysisResults.css to make it look nice:
/* Add to src/components/AnalysisResults.css */
.lab-labels-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.lab-card {
background: white;
padding: 15px;
border-radius: 12px;
border-left: 4px solid #6c5ce7; /* Purple accent */
box-shadow: 0 2px 5px rgba(0,0,0,0.05);
}
.lab-name { font-weight: 700; color: #333; margin-bottom: 4px; text-transform: capitalize; }
.lab-desc { font-size: 0.9rem; color: #555; line-height: 1.4; margin-bottom: 6px; }
.lab-meta { font-size: 0.8rem; color: #888; font-style: italic; }
import './AnalysisResult.css'
import type { RealFoodAnalysis } from '../types'
interface Props {
data: RealFoodAnalysis | null // We now expect the full object, not just a list
loading: boolean
error: string
}
export default function AnalysisResults({ data, loading, error }: Props) {
// Helper for Score Color
const getScoreColor = (score: number) => {
if (score >= 80) return '#2ecc71'; // Green
if (score >= 50) return '#f1c40f'; // Yellow
return '#e74c3c'; // Red
}
// Loading State
if (loading) return (
<div className="results-container loading">
<div className="spinner">🧠</div>
<p>Analyzing Real Food Score...</p>
</div>
)
// Error State
if (error) return <div className="results-container error"><p>⚠️ {error}</p></div>
// Empty State
if (!data) return null;
const { score, bar, reasons, ingredients_breakdown } = data;
return (
<div className="results-container">
{/* 1. BIG SCORE CARD */}
<div className="score-card">
<h3 className="card-title">Real Food Score</h3>
<div className="score-circle" style={{ borderColor: getScoreColor(score) }}>
<span className="score-number" style={{ color: getScoreColor(score) }}>
{score}
</span>
<span className="score-label">/ 100</span>
</div>
</div>
{/* 2. THE VISUAL BAR (Positive vs Negative) */}
<div className="bar-section">
<div className="bar-labels">
<span style={{color: '#2ecc71'}}>Healthy Signs</span>
<span style={{color: '#e74c3c'}}>Processing Signs</span>
</div>
<div className="progress-bar-container">
{/* Green Bar (Positive) */}
<div
className="bar-segment positive"
style={{ flex: bar.positive_ratio || 0.5 }}
/>
{/* Red Bar (Negative) */}
<div
className="bar-segment negative"
style={{ flex: bar.negative_ratio || 0.5 }}
/>
</div>
</div>
{/* 3. TOP REASONS (Bullet Points) */}
<div className="reasons-section">
{reasons.positives.length > 0 && (
<div className="reason-box good">
<h4>✅ Why it's good</h4>
<ul>
{reasons.positives.map((r, i) => <li key={i}>{r}</li>)}
</ul>
</div>
)}
{reasons.concerns.length > 0 && (
<div className="reason-box bad">
<h4>⚠️ Concerns</h4>
<ul>
{reasons.concerns.map((r, i) => <li key={i}>{r}</li>)}
</ul>
</div>
)}
</div>
{/* 4. INGREDIENT BREAKDOWN (Dropdowns) */}
<h3 className="section-header">Ingredient Breakdown</h3>
{/* A. Concerning Ingredients */}
<IngredientDropdown
title="⚠️ Concerning Additives"
items={ingredients_breakdown.concerning}
color="red"
isOpen={true} // Default open
/>
{/* B. Helpful Ingredients */}
<IngredientDropdown
title="🥗 Whole Foods / Helpful"
items={ingredients_breakdown.helpful}
color="green"
/>
{/* C. Mixed / Neutral */}
<IngredientDropdown
title="🤷 Mixed / Complex"
items={ingredients_breakdown.mixed}
color="orange"
/>
<IngredientDropdown
title="🧂 Neutral (Water/Salt)"
items={ingredients_breakdown.neutral}
color="grey"
/>
</div>
)
}
// Mini Component for the Dropdowns
function IngredientDropdown({ title, items, color, isOpen = false }: any) {
if (!items || items.length === 0) return null;
return (
<details className={`ing-dropdown ${color}`} open={isOpen}>
<summary>
{title}
<span className="count-badge">{items.length}</span>
</summary>
<div className="dropdown-content">
{items.map((item: string, i: number) => (
<div key={i} className="ing-chip">{item}</div>
))}
</div>
</details>
)
}
Change this page using that
Here is the updated AnalysisResult.tsx.
I have mapped the new JSON keys (like positive_for_lens) to the UI and added the Lab Labels section at the bottom so you can see the definitions of complex ingredients.
// src/components/AnalysisResult.tsx
import './AnalysisResult.css'
import type { RealFoodAnalysis } from '../types'
interface Props {
data: RealFoodAnalysis | null
loading: boolean
error: string
}
export default function AnalysisResults({ data, loading, error }: Props) {
// Helper for Score Color
const getScoreColor = (score: number) => {
if (score >= 80) return '#2ecc71'; // Green
if (score >= 50) return '#f1c40f'; // Yellow
return '#e74c3c'; // Red
}
// Loading State
if (loading) return (
<div className="results-container loading">
<div className="spinner">🧠</div>
<p>Analyzing Real Food Score...</p>
</div>
)
// Error State
if (error) return <div className="results-container error"><p>⚠️ {error}</p></div>
// Empty State
if (!data) return null;
// Destructure the new data structure
const { score, bar, reasons, ingredients_breakdown, lab_labels } = data;
return (
<div className="results-container">
{/* 1. BIG SCORE CARD */}
<div className="score-card">
<h3 className="card-title">Real Food Score</h3>
<div className="score-circle" style={{ borderColor: getScoreColor(score) }}>
<span className="score-number" style={{ color: getScoreColor(score) }}>
{score}
</span>
<span className="score-label">/ 100</span>
</div>
</div>
{/* 2. THE VISUAL BAR (Positive vs Negative) */}
<div className="bar-section">
<div className="bar-labels">
<span style={{color: '#2ecc71'}}>Real Food</span>
<span style={{color: '#e74c3c'}}>Processed</span>
</div>
<div className="progress-bar-container">
{/* Green Bar (Positive) */}
<div
className="bar-segment positive"
style={{ flex: bar.positive_ratio || 0.05 }} // Min width for visibility
/>
{/* Red Bar (Negative) */}
<div
className="bar-segment negative"
style={{ flex: bar.negative_ratio || 0.05 }}
/>
</div>
</div>
{/* 3. TOP REASONS (Bullet Points) */}
<div className="reasons-section">
{reasons.positives.length > 0 && (
<div className="reason-box good">
<h4>✅ Why it's good</h4>
<ul>
{reasons.positives.map((r, i) => <li key={i}>{r}</li>)}
</ul>
</div>
)}
{reasons.concerns.length > 0 && (
<div className="reason-box bad">
<h4>⚠️ Concerns</h4>
<ul>
{reasons.concerns.map((r, i) => <li key={i}>{r}</li>)}
</ul>
</div>
)}
</div>
{/* 4. INGREDIENT BREAKDOWN (Dropdowns) */}
<h3 className="section-header">Ingredient Breakdown</h3>
{/* A. Concerning Ingredients (negative_for_lens) */}
<IngredientDropdown
title="⚠️ Concerning Additives"
items={ingredients_breakdown.negative_for_lens}
color="red"
isOpen={true} // Default open
/>
{/* B. Helpful Ingredients (positive_for_lens) */}
<IngredientDropdown
title="🥗 Whole Foods / Helpful"
items={ingredients_breakdown.positive_for_lens}
color="green"
/>
{/* C. Mixed / Complex (mixed_for_lens) */}
<IngredientDropdown
title="🤷 Mixed / Complex"
items={ingredients_breakdown.mixed_for_lens}
color="orange"
/>
{/* D. Neutral (neutral_for_lens) */}
<IngredientDropdown
title="🧂 Neutral (Water/Salt)"
items={ingredients_breakdown.neutral_for_lens}
color="grey"
/>
{/* 5. LAB LABELS (New Section) */}
{lab_labels && lab_labels.length > 0 && (
<div className="lab-labels-section">
<h3 className="section-header">📚 Lab Labels</h3>
<div className="lab-labels-list">
{lab_labels.map((label, i) => (
<div key={i} className="lab-card">
<div className="lab-name">{label.ingredient}</div>
<div className="lab-desc">{label.plain_english}</div>
<div className="lab-meta">
<strong>Why added:</strong> {label.why_added}
</div>
</div>
))}
</div>
</div>
)}
</div>
)
}
// Mini Component for the Dropdowns
function IngredientDropdown({ title, items, color, isOpen = false }: any) {
if (!items || items.length === 0) return null;
return (
<details className={`ing-dropdown ${color}`} open={isOpen}>
<summary>
{title}
<span className="count-badge">{items.length}</span>
</summary>
<div className="dropdown-content">
{items.map((item: string, i: number) => (
<div key={i} className="ing-chip">{item}</div>
))}
</div>
</details>
)
}
I can't scroll the container
/* src/components/AnalysisResults.css */
/* 1. MAIN CONTAINER */
.results-container {
width: 100%;
padding-bottom: 80px; /* Space for bottom scroll */
animation: slideUp 0.5s cubic-bezier(0.16, 1, 0.3, 1);
box-sizing: border-box;
}
/* 2. LOADING STATE (The fix for your screenshot) */
.results-container.loading {
height: 100%;
min-height: 50vh; /* Ensure it takes up vertical space */
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
text-align: center;
color: #666;
padding-top: 50px; /* Push down from the top menu button */
}
.spinner {
font-size: 4rem; /* Big Brain */
margin-bottom: 20px;
animation: pulse 1.5s infinite ease-in-out;
}
/* 3. ERROR STATE */
.results-container.error {
padding: 40px 20px;
text-align: center;
color: #d32f2f;
background: #ffebee;
border-radius: 16px;
margin-top: 60px;
border: 1px solid #ffcdd2;
}
/* 4. SCORE CARD (Big Circle) */
.score-card {
background: white;
border-radius: 24px;
padding: 30px 20px;
text-align: center;
box-shadow: 0 10px 30px rgba(0,0,0,0.08); /* Nice soft shadow */
margin-bottom: 25px;
margin-top: 20px; /* Space from top */
}
.card-title {
margin: 0 0 20px 0;
color: #888;
font-size: 0.9rem;
text-transform: uppercase;
letter-spacing: 1.5px;
font-weight: 700;
}
.score-circle {
width: 140px;
height: 140px;
border-radius: 50%;
border: 10px solid #eee; /* Default grey ring */
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
margin: 0 auto;
transition: all 0.5s ease;
position: relative;
}
.score-number {
font-size: 3.5rem;
font-weight: 800;
line-height: 1;
color: #333;
}
.score-label {
font-size: 1rem;
color: #aaa;
margin-top: 5px;
}
/* 5. THE BAR CHART */
.bar-section {
background: white;
padding: 20px;
border-radius: 20px;
margin-bottom: 25px;
box-shadow: 0 4px 15px rgba(0,0,0,0.03);
}
.bar-labels {
display: flex;
justify-content: space-between;
font-size: 0.85rem;
font-weight: 700;
margin-bottom: 12px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.progress-bar-container {
display: flex;
height: 16px; /* Thicker bar */
border-radius: 8px;
overflow: hidden;
background: #f0f0f0;
}
.bar-segment { height: 100%; transition: width 1s ease-out; }
.bar-segment.positive { background: linear-gradient(90deg, #2ecc71, #27ae60); }
.bar-segment.negative { background: linear-gradient(90deg, #e74c3c, #c0392b); }
/* 6. REASONS (Bullet Points) */
.reasons-section { margin-bottom: 30px; }
.reason-box {
background: white;
padding: 18px;
border-radius: 16px;
margin-bottom: 15px;
border-left: 6px solid #ccc;
box-shadow: 0 2px 8px rgba(0,0,0,0.03);
}
.reason-box.good { border-left-color: #2ecc71; background: #f0fdf4; }
.reason-box.bad { border-left-color: #e74c3c; background: #fef2f2; }
.reason-box h4 { margin: 0 0 10px 0; font-size: 1rem; color: #333; }
.reason-box ul { margin: 0; padding-left: 20px; color: #555; }
.reason-box li { margin-bottom: 6px; line-height: 1.4; }
/* 7. DROPDOWNS */
.section-header {
margin: 30px 0 15px;
font-size: 1.2rem;
color: #333;
font-weight: 700;
padding-left: 5px;
}
.ing-dropdown {
background: white;
border-radius: 14px;
margin-bottom: 12px;
overflow: hidden;
box-shadow: 0 2px 6px rgba(0,0,0,0.04);
transition: transform 0.2s;
}
.ing-dropdown summary {
padding: 18px;
font-weight: 600;
cursor: pointer;
display: flex;
justify-content: space-between;
align-items: center;
list-style: none;
font-size: 1rem;
}
.ing-dropdown summary::-webkit-details-marker { display: none; }
/* Status Colors */
.ing-dropdown.red summary { border-left: 6px solid #e74c3c; color: #c0392b; }
.ing-dropdown.green summary { border-left: 6px solid #2ecc71; color: #27ae60; }
.ing-dropdown.orange summary { border-left: 6px solid #f39c12; color: #d35400; }
.ing-dropdown.grey summary { border-left: 6px solid #95a5a6; color: #7f8c8d; }
.count-badge {
background: #f1f3f5;
padding: 4px 10px;
border-radius: 20px;
font-size: 0.85rem;
color: #666;
font-weight: 700;
}
.dropdown-content {
padding: 15px;
background: #fdfdfd;
border-top: 1px solid #f0f0f0;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.ing-chip {
background: white;
border: 1px solid #e0e0e0;
padding: 8px 14px;
border-radius: 8px;
font-size: 0.9rem;
color: #555;
box-shadow: 0 1px 2px rgba(0,0,0,0.03);
}
/* Animations */
@keyframes slideUp {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes pulse {
0% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.1); opacity: 0.8; }
100% { transform: scale(1); opacity: 1; }
}
The reason you cannot scroll is likely not in this file (AnalysisResults.css), but in the parent file (CameraCapture.css). The container holding this result needs to know it's allowed to scroll.
Also, your provided CSS is missing the styles for the new "Lab Labels" section we just added.
Here are the two steps to fix the scrolling and style the new section:
Step 1: Enable Scrolling in src/components/CameraCapture.css
The .results-list is the wrapper that holds the results. It needs overflow-y: auto to allow scrolling.
Replace the .results-list section in src/components/CameraCapture.css with this:
/* src/components/CameraCapture.css */
/* ... (keep existing top styles) ... */
/* --- SCREEN B: RESULTS LIST --- */
.results-list {
background: #f4f6f8;
width: 50%;
height: 100%;
flex-shrink: 0;
position: relative;
/* ⚠️ THIS FIXES THE SCROLLING: */
overflow-y: auto;
overflow-x: hidden;
-webkit-overflow-scrolling: touch; /* Smooth scroll on iPhone */
/* Padding matches the space needed for content + bottom button */
padding: 20px;
padding-bottom: 120px; /* Extra space at bottom so button doesn't cover text */
box-sizing: border-box;
}
/* Hide scrollbar visually but allow scrolling */
.results-list::-webkit-scrollbar { display: none; }
.results-list { -ms-overflow-style: none; scrollbar-width: none; }
Step 2: Add "Lab Labels" styles to AnalysisResults.css
Your provided file was missing the styles for the new "Lab Labels" cards. Add this to the bottom of your AnalysisResults.css:
/* src/components/AnalysisResults.css */
/* ... (Keep all your existing code) ... */
/* 8. NEW: LAB LABELS SECTION */
.lab-labels-section {
margin-top: 30px;
margin-bottom: 20px;
}
.lab-labels-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.lab-card {
background: white;
padding: 16px;
border-radius: 12px;
border-left: 5px solid #6c5ce7; /* Purple accent */
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
}
.lab-name {
font-weight: 800;
color: #333;
margin-bottom: 6px;
text-transform: capitalize;
font-size: 1rem;
}
.lab-desc {
font-size: 0.95rem;
color: #555;
line-height: 1.5;
margin-bottom: 8px;
}
.lab-meta {
font-size: 0.85rem;
color: #888;
font-style: italic;
background: #f8f9fa;
padding: 6px 10px;
border-radius: 6px;
display: inline-block;
}
Why this fixes it:
overflow-y: autoin CameraCapture.css: This tells the browser "If the content is taller than the screen, let the user scroll vertically."padding-bottom: 120px: This ensures the last item (the Lab Labels) isn't hidden behind the floating "Scan New Photo" button at the bottom of the screen.
/* src/components/CameraCapture.css */
/* --- 1. THE MAIN WINDOW (Nuclear Option) --- */
.camera-container {
position: fixed; /* ⚠️ Force full screen, ignoring parent layout */
top: 0;
left: 0;
width: 100vw;
height: 100dvh; /* Mobile-friendly height */
background-color: #000;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
overflow: hidden; /* No scrolling on the container itself */
z-index: 10; /* Sit on top of everything */
}
/* --- 2. THE SLIDING FILM STRIP --- */
.sliding-wrapper {
display: flex;
width: 200%; /* Double width for 2 screens */
height: 100%;
transition: transform 0.4s cubic-bezier(0.25, 1, 0.5, 1); /* Smooth Slide */
}
/* Slide States */
.sliding-wrapper.show-camera { transform: translateX(0); }
.sliding-wrapper.show-results { transform: translateX(-50%); }
/* --- 3. THE TWO SCREENS --- */
.image-area {
width: 50%; /* Each screen is half of the 200% wrapper */
height: 100%;
flex-shrink: 0; /* ⚠️ CRITICAL: Prevents them from squishing! */
position: relative;
}
/* --- SCREEN A: CAMERA --- */
.image-area {
background: #000;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
.photo-preview {
width: 100%;
height: 100%;
object-fit: contain; /* Keep the whole photo visible */
}
.placeholder {
color: white;
text-align: center;
}
/* --- SCREEN B: RESULTS --- */
.results-list {
background: #f4f6f8;
padding: 20px;
padding-bottom: 120px; /* Space for button */
overflow-y: auto;
/* ⚠️ THIS FIXES THE SCROLLING: */
overflow-y: auto;
overflow-x: hidden;
-webkit-overflow-scrolling: touch; /* Smooth scroll on iPhone */
/* Padding matches the space needed for content + bottom button */
padding: 20px;
padding-bottom: 120px; /* Extra space at bottom so button doesn't cover text */
box-sizing: border-box;
}
/* --- 4. THE BUTTON (Fixed wrapping issue) --- */
.controls-area {
position: absolute;
bottom: 40px;
left: 0;
width: 100%;
display: flex;
justify-content: center;
pointer-events: none; /* Click-through */
z-index: 100;
}
.camera-button {
pointer-events: auto; /* Re-enable clicks */
background: #007bff;
color: white;
padding: 16px 32px;
border-radius: 50px;
border: none;
font-size: 1.1rem;
font-weight: 700;
box-shadow: 0 4px 15px rgba(0, 123, 255, 0.4);
/* ⚠️ FIX FOR SQUISHED BUTTON ⚠️ */
white-space: nowrap; /* Never wrap text to next line */
min-width: 200px; /* Minimum width to look good */
text-align: center;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
}
.camera-button:active { transform: scale(0.96); }
/* --- 5. CARD STYLES --- */
.ingredient-card { background: white; border-radius: 12px; margin-bottom: 12px; box-shadow: 0 2px 5px rgba(0,0,0,0.05); overflow: hidden; }
.ingredient-header { padding: 15px; display: flex; justify-content: space-between; align-items: center; list-style: none; }
.ingredient-header::-webkit-details-marker { display: none; }
.ingredient-name { font-weight: 600; color: #333; }
.score-badge { padding: 4px 10px; border-radius: 12px; font-weight: 800; font-size: 0.85rem; }
.ingredient-body { padding: 15px; background: #fafafa; border-top: 1px solid #eee; color: #555; line-height: 1.5; }
/* Colors */
.safe .ingredient-header { border-left: 5px solid #2ecc71; } .safe .score-badge { background: #e8f8f5; color: #27ae60; }
.caution .ingredient-header { border-left: 5px solid #f1c40f; } .caution .score-badge { background: #fef9e7; color: #b7791f; }
.danger .ingredient-header { border-left: 5px solid #e74c3c; } .danger .score-badge { background: #fadbd8; color: #c0392b; }
.loading-text, .error-text { text-align: center; margin-top: 40px; color: #666; }
.error-text { color: #d32f2f; }
Here is complete code for CameraCapture
You are very close! However, the code you pasted is missing three critical lines in the .results-list section.
Without width: 50%, height: 100%, and flex-shrink: 0, the results screen will either disappear, get squashed, or refuse to scroll.
Here is the corrected and final src/components/CameraCapture.css.
src/components/CameraCapture.css
/* src/components/CameraCapture.css */
/* --- 1. THE MAIN WINDOW (Nuclear Option) --- */
.camera-container {
position: fixed; /* Force full screen */
top: 0;
left: 0;
width: 100vw;
height: 100dvh; /* Mobile-friendly height */
background-color: #000;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
overflow: hidden; /* No scrolling on the main container */
z-index: 10;
}
/* --- 2. THE SLIDING FILM STRIP --- */
.sliding-wrapper {
display: flex;
width: 200%; /* Double width for 2 screens */
height: 100%;
transition: transform 0.4s cubic-bezier(0.25, 1, 0.5, 1);
}
/* Slide States */
.sliding-wrapper.show-camera { transform: translateX(0); }
.sliding-wrapper.show-results { transform: translateX(-50%); }
/* --- 3. THE TWO SCREENS --- */
/* SCREEN A: CAMERA */
.image-area {
width: 50%; /* Half of the 200% wrapper */
height: 100%;
flex-shrink: 0; /* Don't squish */
position: relative;
background: #000;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
.photo-preview {
width: 100%;
height: 100%;
object-fit: contain;
}
.placeholder {
color: white;
text-align: center;
}
/* SCREEN B: RESULTS */
.results-list {
background: #f4f6f8;
/* ⚠️ CRITICAL FIXES START HERE ⚠️ */
width: 50%; /* Must be 50% to match the camera side */
height: 100%; /* Must be 100% to allow scrolling inside */
flex-shrink: 0; /* Prevent squishing */
position: relative;
/* ⚠️ CRITICAL FIXES END ⚠️ */
/* Scrolling Logic */
overflow-y: auto;
overflow-x: hidden;
-webkit-overflow-scrolling: touch; /* Smooth scroll on iPhone */
/* Padding matches the space needed for content + bottom button */
padding: 20px;
padding-bottom: 120px; /* Extra space so button doesn't cover text */
box-sizing: border-box;
}
/* Hide scrollbar but keep functionality */
.results-list::-webkit-scrollbar { display: none; }
.results-list { -ms-overflow-style: none; scrollbar-width: none; }
/* --- 4. THE BUTTON --- */
.controls-area {
position: absolute;
bottom: 40px;
left: 0;
width: 100%;
display: flex;
justify-content: center;
pointer-events: none; /* Click-through */
z-index: 100;
}
.camera-button {
pointer-events: auto; /* Re-enable clicks */
background: #007bff;
color: white;
padding: 16px 32px;
border-radius: 50px;
border: none;
font-size: 1.1rem;
font-weight: 700;
box-shadow: 0 4px 15px rgba(0, 123, 255, 0.4);
white-space: nowrap;
min-width: 200px;
text-align: center;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
}
.camera-button:active { transform: scale(0.96); }
/* --- 5. LEGACY STYLES (Optional) --- */
/* These are likely handled by AnalysisResults.css now, but safe to keep here just in case */
.loading-text, .error-text { text-align: center; margin-top: 40px; color: #666; }
.error-text { color: #d32f2f; }
Why I changed .results-list:
The version you posted was missing height: 100% and width: 50%.
Without
height: 100%, the container grows infinitely with the content, so the browser never thinks it needs a scrollbar (scrolling breaks).Without
width: 50%, the sliding animation might look broken or empty.
Save this file, and your scrolling should work perfectly!
/* src/components/AnalysisResults.css */
/* 1. MAIN CONTAINER */
.results-container {
width: 100%;
padding-bottom: 80px; /* Space for bottom scroll */
animation: slideUp 0.5s cubic-bezier(0.16, 1, 0.3, 1);
box-sizing: border-box;
}
/* 2. LOADING STATE (The fix for your screenshot) */
.results-container.loading {
height: 100%;
min-height: 50vh; /* Ensure it takes up vertical space */
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
text-align: center;
color: #666;
padding-top: 50px; /* Push down from the top menu button */
}
.spinner {
font-size: 4rem; /* Big Brain */
margin-bottom: 20px;
animation: pulse 1.5s infinite ease-in-out;
}
/* 3. ERROR STATE */
.results-container.error {
padding: 40px 20px;
text-align: center;
color: #d32f2f;
background: #ffebee;
border-radius: 16px;
margin-top: 60px;
border: 1px solid #ffcdd2;
}
/* 4. SCORE CARD (Big Circle) */
.score-card {
background: white;
border-radius: 24px;
padding: 30px 20px;
text-align: center;
box-shadow: 0 10px 30px rgba(0,0,0,0.08); /* Nice soft shadow */
margin-bottom: 25px;
margin-top: 20px; /* Space from top */
}
.card-title {
margin: 0 0 20px 0;
color: #888;
font-size: 0.9rem;
text-transform: uppercase;
letter-spacing: 1.5px;
font-weight: 700;
}
.score-circle {
width: 140px;
height: 140px;
border-radius: 50%;
border: 10px solid #eee; /* Default grey ring */
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
margin: 0 auto;
transition: all 0.5s ease;
position: relative;
}
.score-number {
font-size: 3.5rem;
font-weight: 800;
line-height: 1;
color: #333;
}
.score-label {
font-size: 1rem;
color: #aaa;
margin-top: 5px;
}
/* 5. THE BAR CHART */
.bar-section {
background: white;
padding: 20px;
border-radius: 20px;
margin-bottom: 25px;
box-shadow: 0 4px 15px rgba(0,0,0,0.03);
}
.bar-labels {
display: flex;
justify-content: space-between;
font-size: 0.85rem;
font-weight: 700;
margin-bottom: 12px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.progress-bar-container {
display: flex;
height: 16px; /* Thicker bar */
border-radius: 8px;
overflow: hidden;
background: #f0f0f0;
}
.bar-segment { height: 100%; transition: width 1s ease-out; }
.bar-segment.positive { background: linear-gradient(90deg, #2ecc71, #27ae60); }
.bar-segment.negative { background: linear-gradient(90deg, #e74c3c, #c0392b); }
/* 6. REASONS (Bullet Points) */
.reasons-section { margin-bottom: 30px; }
.reason-box {
background: white;
padding: 18px;
border-radius: 16px;
margin-bottom: 15px;
border-left: 6px solid #ccc;
box-shadow: 0 2px 8px rgba(0,0,0,0.03);
}
.reason-box.good { border-left-color: #2ecc71; background: #f0fdf4; }
.reason-box.bad { border-left-color: #e74c3c; background: #fef2f2; }
.reason-box h4 { margin: 0 0 10px 0; font-size: 1rem; color: #333; }
.reason-box ul { margin: 0; padding-left: 20px; color: #555; }
.reason-box li { margin-bottom: 6px; line-height: 1.4; }
/* 7. DROPDOWNS */
.section-header {
margin: 30px 0 15px;
font-size: 1.2rem;
color: #333;
font-weight: 700;
padding-left: 5px;
}
.ing-dropdown {
background: white;
border-radius: 14px;
margin-bottom: 12px;
overflow: hidden;
box-shadow: 0 2px 6px rgba(0,0,0,0.04);
transition: transform 0.2s;
}
.ing-dropdown summary {
padding: 18px;
font-weight: 600;
cursor: pointer;
display: flex;
justify-content: space-between;
align-items: center;
list-style: none;
font-size: 1rem;
}
.ing-dropdown summary::-webkit-details-marker { display: none; }
/* Status Colors */
.ing-dropdown.red summary { border-left: 6px solid #e74c3c; color: #c0392b; }
.ing-dropdown.green summary { border-left: 6px solid #2ecc71; color: #27ae60; }
.ing-dropdown.orange summary { border-left: 6px solid #f39c12; color: #d35400; }
.ing-dropdown.grey summary { border-left: 6px solid #95a5a6; color: #7f8c8d; }
.count-badge {
background: #f1f3f5;
padding: 4px 10px;
border-radius: 20px;
font-size: 0.85rem;
color: #666;
font-weight: 700;
}
.dropdown-content {
padding: 15px;
background: #fdfdfd;
border-top: 1px solid #f0f0f0;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.ing-chip {
background: white;
border: 1px solid #e0e0e0;
padding: 8px 14px;
border-radius: 8px;
font-size: 0.9rem;
color: #555;
box-shadow: 0 1px 2px rgba(0,0,0,0.03);
}
/* Animations */
@keyframes slideUp {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes pulse {
0% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.1); opacity: 0.8; }
100% { transform: scale(1); opacity: 1; }
}
This is AnalysisResult.css
You are correctly spotting that the CSS for the Lab Labels (the new section we added for definitions) is missing from this file!
Here is the Complete AnalysisResults.css. I have added the missing Section 8 at the bottom so the "Lab Labels" look like nice purple cards, and I tweaked the container to play nicely with the scrolling fix we did in the other file.
src/components/AnalysisResults.css
/* src/components/AnalysisResults.css */
/* 1. MAIN CONTAINER */
.results-container {
width: 100%;
/* Padding bottom is handled by the parent .results-list in CameraCapture.css now,
but we keep a little here just in case */
padding-bottom: 40px;
animation: slideUp 0.5s cubic-bezier(0.16, 1, 0.3, 1);
box-sizing: border-box;
}
/* 2. LOADING STATE */
.results-container.loading {
height: 100%;
min-height: 50vh;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
text-align: center;
color: #666;
padding-top: 50px;
}
.spinner {
font-size: 4rem;
margin-bottom: 20px;
animation: pulse 1.5s infinite ease-in-out;
}
/* 3. ERROR STATE */
.results-container.error {
padding: 40px 20px;
text-align: center;
color: #d32f2f;
background: #ffebee;
border-radius: 16px;
margin-top: 60px;
border: 1px solid #ffcdd2;
}
/* 4. SCORE CARD (Big Circle) */
.score-card {
background: white;
border-radius: 24px;
padding: 30px 20px;
text-align: center;
box-shadow: 0 10px 30px rgba(0,0,0,0.08);
margin-bottom: 25px;
margin-top: 20px;
}
.card-title {
margin: 0 0 20px 0;
color: #888;
font-size: 0.9rem;
text-transform: uppercase;
letter-spacing: 1.5px;
font-weight: 700;
}
.score-circle {
width: 140px;
height: 140px;
border-radius: 50%;
border: 10px solid #eee;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
margin: 0 auto;
transition: all 0.5s ease;
position: relative;
}
.score-number {
font-size: 3.5rem;
font-weight: 800;
line-height: 1;
color: #333;
}
.score-label {
font-size: 1rem;
color: #aaa;
margin-top: 5px;
}
/* 5. THE BAR CHART */
.bar-section {
background: white;
padding: 20px;
border-radius: 20px;
margin-bottom: 25px;
box-shadow: 0 4px 15px rgba(0,0,0,0.03);
}
.bar-labels {
display: flex;
justify-content: space-between;
font-size: 0.85rem;
font-weight: 700;
margin-bottom: 12px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.progress-bar-container {
display: flex;
height: 16px;
border-radius: 8px;
overflow: hidden;
background: #f0f0f0;
}
.bar-segment { height: 100%; transition: width 1s ease-out; }
.bar-segment.positive { background: linear-gradient(90deg, #2ecc71, #27ae60); }
.bar-segment.negative { background: linear-gradient(90deg, #e74c3c, #c0392b); }
/* 6. REASONS (Bullet Points) */
.reasons-section { margin-bottom: 30px; }
.reason-box {
background: white;
padding: 18px;
border-radius: 16px;
margin-bottom: 15px;
border-left: 6px solid #ccc;
box-shadow: 0 2px 8px rgba(0,0,0,0.03);
}
.reason-box.good { border-left-color: #2ecc71; background: #f0fdf4; }
.reason-box.bad { border-left-color: #e74c3c; background: #fef2f2; }
.reason-box h4 { margin: 0 0 10px 0; font-size: 1rem; color: #333; }
.reason-box ul { margin: 0; padding-left: 20px; color: #555; }
.reason-box li { margin-bottom: 6px; line-height: 1.4; }
/* 7. DROPDOWNS */
.section-header {
margin: 30px 0 15px;
font-size: 1.2rem;
color: #333;
font-weight: 700;
padding-left: 5px;
}
.ing-dropdown {
background: white;
border-radius: 14px;
margin-bottom: 12px;
overflow: hidden;
box-shadow: 0 2px 6px rgba(0,0,0,0.04);
transition: transform 0.2s;
}
.ing-dropdown summary {
padding: 18px;
font-weight: 600;
cursor: pointer;
display: flex;
justify-content: space-between;
align-items: center;
list-style: none;
font-size: 1rem;
}
.ing-dropdown summary::-webkit-details-marker { display: none; }
/* Status Colors */
.ing-dropdown.red summary { border-left: 6px solid #e74c3c; color: #c0392b; }
.ing-dropdown.green summary { border-left: 6px solid #2ecc71; color: #27ae60; }
.ing-dropdown.orange summary { border-left: 6px solid #f39c12; color: #d35400; }
.ing-dropdown.grey summary { border-left: 6px solid #95a5a6; color: #7f8c8d; }
.count-badge {
background: #f1f3f5;
padding: 4px 10px;
border-radius: 20px;
font-size: 0.85rem;
color: #666;
font-weight: 700;
}
.dropdown-content {
padding: 15px;
background: #fdfdfd;
border-top: 1px solid #f0f0f0;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.ing-chip {
background: white;
border: 1px solid #e0e0e0;
padding: 8px 14px;
border-radius: 8px;
font-size: 0.9rem;
color: #555;
box-shadow: 0 1px 2px rgba(0,0,0,0.03);
}
/* 8. NEW: LAB LABELS SECTION (Purple Cards) */
.lab-labels-section {
margin-top: 30px;
margin-bottom: 20px;
}
.lab-labels-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.lab-card {
background: white;
padding: 16px;
border-radius: 12px;
border-left: 5px solid #6c5ce7; /* Purple accent */
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
}
.lab-name {
font-weight: 800;
color: #333;
margin-bottom: 6px;
text-transform: capitalize;
font-size: 1rem;
}
.lab-desc {
font-size: 0.95rem;
color: #555;
line-height: 1.5;
margin-bottom: 8px;
}
.lab-meta {
font-size: 0.85rem;
color: #888;
font-style: italic;
background: #f8f9fa;
padding: 6px 10px;
border-radius: 6px;
display: inline-block;
}
/* Animations */
@keyframes slideUp {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes pulse {
0% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.1); opacity: 0.8; }
100% { transform: scale(1); opacity: 1; }
}
⚠️ IMPORTANT REMINDER
This file controls how the cards Look.
To fix the Scrolling, you must ensure your src/components/CameraCapture.css has this exact block we discussed earlier:
/* In CameraCapture.css */
.results-list {
/* ... other styles ... */
height: 100%; /* 1. Allows scrolling */
overflow-y: auto; /* 2. Turns on scrollbar */
padding-bottom: 120px; /* 3. Clears space for button */
}
still can't scroll
This is usually caused by the "height chain" being broken. In CSS, if you say height: 100%, it only works if the parent also has a fixed height. If one parent allows itself to grow (like a flex container), the scrollbar never appears because the browser thinks, "Oh, I'll just make the page taller to fit the content!"
Here is the Nuclear Fix. We are going to force the specific list to be exactly the height of the screen (100dvh), forcing anything inside it to scroll if it gets too big.
1. Update src/components/CameraCapture.css
Replace your .results-list block with this exact code. I have changed height: 100% to height: 100dvh and added !important to force the scroll behavior.
/* src/components/CameraCapture.css */
/* ... keep previous styles ... */
/* SCREEN B: RESULTS */
.results-list {
background: #f4f6f8;
/* ⚠️ THE SCROLL FIX ⚠️ */
/* 1. Force the container to be exactly the phone screen height */
height: 100dvh;
max-height: 100dvh;
/* 2. Force scrollbar to appear if content is too long */
overflow-y: scroll !important;
overflow-x: hidden;
/* 3. Smooth scrolling for iOS */
-webkit-overflow-scrolling: touch;
/* 4. Layout fixes */
width: 50%;
flex-shrink: 0;
position: relative;
box-sizing: border-box;
/* 5. Padding at bottom so you can scroll past the button */
padding: 20px;
padding-bottom: 150px;
}
/* Optional: Hide the ugly grey scrollbar, but keep scrolling working */
.results-list::-webkit-scrollbar {
display: none;
}
.results-list {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
2. Update src/components/AnalysisResults.css
We need to make sure the child content isn't accidentally fighting the scroll.
Find .results-container and make sure it looks like this (Remove height: 100% if you had it there, except for the loading state).
/* src/components/AnalysisResults.css */
/* 1. MAIN CONTAINER */
.results-container {
width: 100%;
/* ⚠️ Do NOT put 'height: 100%' here, or it will clip! */
height: auto;
overflow: visible; /* Let the parent (.results-list) handle the scroll */
padding-bottom: 40px;
animation: slideUp 0.5s cubic-bezier(0.16, 1, 0.3, 1);
box-sizing: border-box;
}
/* ... keep the rest of your file ... */
Why this works:
100dvh(Dynamic Viewport Height): This forces the.results-listto be exactly the size of your mobile screen, regardless of what the parent container thinks.overflow-y: scroll !important: This overrides any browser default that might be trying to hide the overflow.height: autoon the child: This ensures the content actually grows long enough to trigger the scrollbar in the parent.
// src/components/CameraCapture.tsx
import { useState, type ChangeEvent } from 'react'
import './CameraCapture.css'
import { type RealFoodAnalysis } from '../types' // Import new component
import AnalysisResults from './AnalysisResult' // Import the new results component
export default function CameraCapture() {
const [image, setImage] = useState<string | null>(null)
const [realFoodAnalysis, setRealFoodAnalysis] = useState<RealFoodAnalysis | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string>("")
const handleImageChange = async (e: ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files[0]) {
const file = e.target.files[0]
const url = URL.createObjectURL(file)
setImage(url)
// Reset states
setRealFoodAnalysis(null)
setError("")
setLoading(true)
const formData = new FormData()
formData.append("file", file)
try {
// ⚠️ Make sure this IP matches your computer!
const response = await fetch("http://10.189.4.149:8000/api/analyzehardcoded", {
method: "POST",
body: formData,
})
const data = await response.json()
// Parse the JSON string from Backboard
let parsedData;
try {
parsedData = typeof data.health_analysis === 'string'
? JSON.parse(data.health_analysis)
: data.health_analysis;
} catch (e) {
parsedData = data.health_analysis;
}
if (parsedData) {
setRealFoodAnalysis(parsedData)
} else {
setError("Could not read ingredients format.")
}
} catch (err) {
setError("Error: Could not reach the brain! 🧠❌")
} finally {
setLoading(false)
}
}
}
const hasResults = realFoodAnalysis !== null || loading; // Keep results screen open if loading
return (
<div className="camera-container">
{/* THE SLIDING WRAPPER */}
<div className={`sliding-wrapper ${hasResults ? 'show-results' : 'show-camera'}`}>
{/* LEFT SIDE: Camera */}
<div className="image-area">
{image ? (
<img src={image} alt="Captured" className="photo-preview" />
) : (
<div className="placeholder">
<div style={{fontSize: '3rem', marginBottom: '10px'}}>📸</div>
<p>Take a photo</p>
</div>
)}
</div>
{/* RIGHT SIDE: The New Results Component */}
<AnalysisResults
data={realFoodAnalysis}
loading={loading}
error={error}
/>
</div>
{/* BUTTON (Always visible) */}
<div className="controls-area">
<input
type="file"
accept="image/*"
capture="environment"
onChange={handleImageChange}
id="cameraInput"
style={{ display: 'none' }}
/>
<label htmlFor="cameraInput" className="camera-button">
{loading ? "Scanning..." : (realFoodAnalysis ? "📸 Scan New" : "📸 Take Photo")}
</label>
</div>
</div>
)
}
I found the issue! In your CSS, we created a class called .results-list to handle the scrolling and the 50% width, but you are not using that class in your JSX.
You are dropping <AnalysisResults /> directly into the slider, so it doesn't know it needs to be a scrollable container.
Here is the fixed src/components/CameraCapture.tsx. I have wrapped the results component in a div with className="results-list".
src/components/CameraCapture.tsx
import { useState, type ChangeEvent } from 'react'
import './CameraCapture.css'
import { type RealFoodAnalysis } from '../types'
import AnalysisResults from './AnalysisResult'
export default function CameraCapture() {
const [image, setImage] = useState<string | null>(null)
const [realFoodAnalysis, setRealFoodAnalysis] = useState<RealFoodAnalysis | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string>("")
const handleImageChange = async (e: ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files[0]) {
const file = e.target.files[0]
const url = URL.createObjectURL(file)
setImage(url)
// Reset states
setRealFoodAnalysis(null)
setError("")
setLoading(true)
const formData = new FormData()
formData.append("file", file)
try {
// ⚠️ Make sure this matches your backend URL (e.g., ngrok or local IP)
const response = await fetch("http://10.189.4.149:8000/api/analyzehardcoded", {
method: "POST",
body: formData,
})
const data = await response.json()
// Parse the JSON string safely
let parsedData;
try {
parsedData = typeof data.health_analysis === 'string'
? JSON.parse(data.health_analysis)
: data.health_analysis;
} catch (e) {
parsedData = data.health_analysis;
}
if (parsedData) {
setRealFoodAnalysis(parsedData)
} else {
setError("Could not read ingredients format.")
}
} catch (err) {
setError("Error: Could not reach the brain! 🧠❌")
} finally {
setLoading(false)
}
}
}
const hasResults = realFoodAnalysis !== null || loading;
return (
<div className="camera-container">
{/* THE SLIDING WRAPPER */}
<div className={`sliding-wrapper ${hasResults ? 'show-results' : 'show-camera'}`}>
{/* LEFT SIDE: Camera */}
<div className="image-area">
{image ? (
<img src={image} alt="Captured" className="photo-preview" />
) : (
<div className="placeholder">
<div style={{fontSize: '3rem', marginBottom: '10px'}}>📸</div>
<p>Take a photo</p>
</div>
)}
</div>
{/* ⚠️ RIGHT SIDE: The Scrollable Wrapper
This div className="results-list" is what enables scrolling!
*/}
<div className="results-list">
<AnalysisResults
data={realFoodAnalysis}
loading={loading}
error={error}
/>
</div>
</div>
{/* BUTTON (Always visible) */}
<div className="controls-area">
<input
type="file"
accept="image/*"
capture="environment"
onChange={handleImageChange}
id="cameraInput"
style={{ display: 'none' }}
/>
<label htmlFor="cameraInput" className="camera-button">
{loading ? "Scanning..." : (realFoodAnalysis ? "📸 Scan New" : "📸 Take Photo")}
</label>
</div>
</div>
)
}
Why this fixes the scrolling:
The CSS we wrote earlier targets .results-list to apply overflow-y: scroll. By wrapping <AnalysisResults /> inside this div, the browser now knows:
This section is exactly 50% of the slider.
If the content is taller than the screen, show a scrollbar inside this specific div.
ok now next step:
On the cameracapture, on top of the camera display there will be 3 buttons to choose with title and an icon:
1. Focus (a magnifier)
2. Real Food (a salad)
3. Personal (a person icon)
It will default real food. When the user submit the picture it will send the picture with the lense value
This adds a "Lens Switcher" to the top of your camera screen. It sends the selected lens (Focus, Real Food, or Personal) along with the image to your backend.
1. Update src/components/CameraCapture.tsx
I have added the Lens State, the Buttons, and updated the Fetch Request to send the lens value.
// src/components/CameraCapture.tsx
import { useState, type ChangeEvent } from 'react'
import './CameraCapture.css'
import { type RealFoodAnalysis } from '../types'
import AnalysisResults from './AnalysisResult'
// Define the available lenses
type LensType = 'focus' | 'real_food' | 'personal';
export default function CameraCapture() {
const [image, setImage] = useState<string | null>(null)
const [realFoodAnalysis, setRealFoodAnalysis] = useState<RealFoodAnalysis | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string>("")
// 1. NEW STATE: Default to "real_food"
const [selectedLens, setSelectedLens] = useState<LensType>('real_food')
const handleImageChange = async (e: ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files[0]) {
const file = e.target.files[0]
const url = URL.createObjectURL(file)
setImage(url)
setRealFoodAnalysis(null)
setError("")
setLoading(true)
const formData = new FormData()
formData.append("file", file)
// 2. SEND THE LENS VALUE
formData.append("lens", selectedLens)
try {
// ⚠️ Ensure this URL matches your backend setup
const response = await fetch("http://10.189.4.149:8000/api/analyzehardcoded", {
method: "POST",
body: formData,
})
const data = await response.json()
let parsedData;
try {
parsedData = typeof data.health_analysis === 'string'
? JSON.parse(data.health_analysis)
: data.health_analysis;
} catch (e) {
parsedData = data.health_analysis;
}
if (parsedData) {
setRealFoodAnalysis(parsedData)
} else {
setError("Could not read ingredients format.")
}
} catch (err) {
setError("Error: Could not reach the brain! 🧠❌")
} finally {
setLoading(false)
}
}
}
const hasResults = realFoodAnalysis !== null || loading;
return (
<div className="camera-container">
<div className={`sliding-wrapper ${hasResults ? 'show-results' : 'show-camera'}`}>
{/* LEFT SIDE: Camera */}
<div className="image-area">
{/* 3. NEW: LENS SELECTOR OVERLAY */}
{/* Only show if we haven't taken a photo yet, or just always show it?
Usually nice to hide while loading, but keeping it visible is fine too. */}
{!image && (
<div className="lens-overlay">
<button
className={`lens-btn ${selectedLens === 'focus' ? 'active' : ''}`}
onClick={() => setSelectedLens('focus')}
>
<span className="lens-icon">🔍</span>
<span className="lens-label">Focus</span>
</button>
<button
className={`lens-btn ${selectedLens === 'real_food' ? 'active' : ''}`}
onClick={() => setSelectedLens('real_food')}
>
<span className="lens-icon">🥗</span>
<span className="lens-label">Real Food</span>
</button>
<button
className={`lens-btn ${selectedLens === 'personal' ? 'active' : ''}`}
onClick={() => setSelectedLens('personal')}
>
<span className="lens-icon">👤</span>
<span className="lens-label">Personal</span>
</button>
</div>
)}
{image ? (
<img src={image} alt="Captured" className="photo-preview" />
) : (
<div className="placeholder">
<div style={{fontSize: '3rem', marginBottom: '10px'}}>📸</div>
<p>Take a photo</p>
</div>
)}
</div>
{/* RIGHT SIDE: Results */}
<div className="results-list">
<AnalysisResults
data={realFoodAnalysis}
loading={loading}
error={error}
/>
</div>
</div>
<div className="controls-area">
<input
type="file"
accept="image/*"
capture="environment"
onChange={handleImageChange}
id="cameraInput"
style={{ display: 'none' }}
/>
<label htmlFor="cameraInput" className="camera-button">
{loading ? "Scanning..." : (realFoodAnalysis ? "📸 Scan New" : "📸 Take Photo")}
</label>
</div>
</div>
)
}
2. Update src/components/CameraCapture.css
Add this styling to make the buttons look like a cool floating menu on top of the camera.
/* src/components/CameraCapture.css */
/* ... Keep all existing styles ... */
/* --- NEW: LENS SELECTOR OVERLAY --- */
.lens-overlay {
position: absolute;
top: 40px; /* Space from top of phone screen */
left: 50%;
transform: translateX(-50%);
z-index: 20; /* Sit above the camera placeholder */
display: flex;
background: rgba(0, 0, 0, 0.6); /* Semi-transparent black */
backdrop-filter: blur(10px); /* Blur effect like iOS */
padding: 5px;
border-radius: 30px;
gap: 5px;
}
.lens-btn {
background: transparent;
border: none;
color: #ccc;
padding: 8px 16px;
border-radius: 25px;
display: flex;
flex-direction: column;
align-items: center;
font-size: 0.75rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
min-width: 70px;
}
.lens-icon {
font-size: 1.2rem;
margin-bottom: 3px;
}
/* Active State (Selected) */
.lens-btn.active {
background: white;
color: #000;
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
transform: scale(1.05);
}
/* Specific active colors per lens (Optional, but nice) */
/* Real Food = Green hint when active */
.lens-btn.active:nth-child(2) { color: #2ecc71; }
Backend Note
Since you are updating formData.append("lens", selectedLens), your Python backend can now read this value.
If you want to use it in Python later, update your endpoint signature:
# In main.py
from fastapi import Form # Import Form
@app.post("/api/analyze")
async def analyze_image(
file: UploadFile = File(...),
lens: str = Form(...) # <--- Receive the lens value here!
):
print(f"Selected Lens: {lens}")
# Logic to switch prompts based on lens...
@app.post("/api/analyzehardcoded")
async def analyze_image(file: UploadFile = File(...)):
# --- STEP 1: READ IMAGE ---
print("📸 Receiving image...")
contents = await file.read()
image = Image.open(io.BytesIO(contents))
clean_json = """
{
"lens": "real_food_score",
"score": 36,
"bar": {
"positive_points": 0,
"negative_points": 64,
"positive_ratio": 0.0,
"negative_ratio": 1.0
},
"reasons": {
"positives": [],
"concerns": [
"⚠️ Presence of added sugars: sugar, glucose-fructose.",
"⚠️ Contains artificial flavor, a marker of processing.",
"⚠️ Complexity of ingredient list with 13 ingredients."
]
},
"criteria_hits": [
{
"id": "P3",
"name": "Added sugar indicators",
"direction": "negative",
"points": 12,
"evidence": "sugar, glucose-fructose"
},
{
"id": "P4",
"name": "Flavors marker",
"direction": "negative",
"points": 8,
"evidence": "artificial flavour"
},
{
"id": "P8",
"name": "Ingredient list complexity",
"direction": "negative",
"points": 10,
"evidence": "13 ingredients"
}
],
"ingredients_breakdown": {
"positive_for_lens": [],
"negative_for_lens": [
"sugars (sugar, glucose-fructose)",
"artificial flavour",
"caramel"
],
"mixed_for_lens": [
"niacinamide",
"pyridoxine hydrochloride (vitamin B6)",
"calcium d-pantothenate",
"cyanocobalamin (vitamin b12)",
"taurine",
"riboflavin"
],
"neutral_for_lens": [
"carbonated water",
"citric acid",
"sodium bicarbonate",
"magnesium carbonate",
"caffeine"
]
},
"lab_labels": [
{
"ingredient": "taurine",
"plain_english": "An organic compound often added to energy drinks.",
"why_added": "Used to potentially enhance physical and mental performance.",
"common_in": ["energy drinks"]
},
{
"ingredient": "niacinamide",
"plain_english": "A form of vitamin B3.",
"why_added": "Used as a dietary supplement or fortification for added nutrition.",
"common_in": ["supplements", "fortified foods"]
},
{
"ingredient": "pyridoxine hydrochloride (vitamin B6)",
"plain_english": "A form of vitamin B6.",
"why_added": "Used as a dietary supplement for added nutrition.",
"common_in": ["supplements", "fortified foods"]
},
{
"ingredient": "calcium d-pantothenate",
"plain_english": "A form of vitamin B5.",
"why_added": "Used as a dietary supplement for added nutrition.",
"common_in": ["supplements", "fortified foods"]
},
{
"ingredient": "cyanocobalamin (vitamin B12)",
"plain_english": "A synthetic form of vitamin B12.",
"why_added": "Used as a dietary supplement or fortification for added nutrition.",
"common_in": ["supplements", "fortified foods"]
},
{
"ingredient": "riboflavin",
"plain_english": "Also known as vitamin B2.",
"why_added": "Used for nutritional fortification.",
"common_in": ["fortified cereals", "supplements"]
}
],
"detected_signals": {
"first_ingredient": "carbonated water",
"ingredient_count": 13,
"sugars": {
"terms": ["sugar", "glucose-fructose"],
"positions": [1]
},
"sweeteners": [],
"colors": [],
"flavors": ["artificial flavour"],
"emulsifiers": [],
"preservatives": [],
"isolates": []
},
"sources_consulted": [
{
"source": "NOVA food processing framework",
"how_used": "Defines ultra-processed markers (colors, flavors, emulsifiers, sweeteners, reconstituted ingredients)."
},
{
"source": "Health Canada – Lists of Permitted Food Additives",
"how_used": "Frames additives as regulated/permitted while still being processing markers."
},
{
"source": "FDA – Food additives/GRAS overview",
"how_used": "Avoids unsafe claims; permitted additives can still indicate processing."
},
{
"source": "EFSA – Food additives topic pages",
"how_used": "Reference for additive assessment framing; supports cautious language."
}
],
"notes": []
}
"""
return {
"health_analysis": clean_json
}
For this one, i want to do conditional that if it's focus lense it will output this json, if it's other it will output other json
To handle the different lenses ("Focus", "Real Food", "Personal"), we need to update your backend to:
Accept the
lensparameter from the form data.Use an
if/elsestatement to return different JSON data based on that lens.
Here is the updated backend/main.py. I have created unique, realistic data for Focus (checking for brain fog/energy) and Personal (checking for gut health/allergens) to show the difference.
Update backend/main.py
from fastapi import FastAPI, UploadFile, File, Form
from fastapi.middleware.cors import CORSMiddleware
import uvicorn
import io
from PIL import Image
app = FastAPI()
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.post("/api/analyzehardcoded")
async def analyze_image(
file: UploadFile = File(...),
lens: str = Form(...) # <--- 1. Receive the lens here
):
print(f"📸 Receiving image for lens: {lens}")
# Simulate reading the file
await file.read()
# --- 2. SWITCH LOGIC BASED ON LENS ---
# === A. FOCUS LENS (Brain Power) ===
if lens == "focus":
json_data = """
{
"lens": "focus",
"score": 65,
"bar": {
"positive_ratio": 0.6,
"negative_ratio": 0.4
},
"reasons": {
"positives": [
"✅ Contains Caffeine for immediate alertness.",
"✅ Vitamin B12 & B6 support cognitive function.",
"✅ Taurine may help reduce mental fatigue."
],
"concerns": [
"⚠️ High sugar content leads to energy crashes.",
"⚠️ Artificial colors have been linked to attention issues.",
"⚠️ Lack of healthy fats for sustained brain fuel."
]
},
"ingredients_breakdown": {
"positive_for_lens": ["caffeine", "cyanocobalamin (vitamin b12)", "pyridoxine hydrochloride (vitamin B6)", "taurine"],
"negative_for_lens": ["sugar", "glucose-fructose", "red 40"],
"mixed_for_lens": ["niacinamide"],
"neutral_for_lens": ["carbonated water", "citric acid"]
},
"lab_labels": [
{
"ingredient": "taurine",
"plain_english": "An amino acid that supports nerve growth.",
"why_added": "Included to smooth out the jitters from caffeine.",
"common_in": ["energy drinks"]
}
],
"detected_signals": {},
"sources_consulted": [],
"notes": []
}
"""
# === B. PERSONAL LENS (e.g., Gut Health / Sensitivity) ===
elif lens == "personal":
json_data = """
{
"lens": "personal",
"score": 20,
"bar": {
"positive_ratio": 0.1,
"negative_ratio": 0.9
},
"reasons": {
"positives": [
"✅ No dairy ingredients detected (Good for lactose intolerance)."
],
"concerns": [
"⚠️ Artificial sweeteners can disrupt gut microbiome.",
"⚠️ Carbonation may cause bloating.",
"⚠️ Food dyes are a common personal allergen."
]
},
"ingredients_breakdown": {
"positive_for_lens": [],
"negative_for_lens": ["sucralose", "acesulfame potassium", "red 40", "blue 1"],
"mixed_for_lens": ["natural flavors"],
"neutral_for_lens": ["water"]
},
"lab_labels": [
{
"ingredient": "sucralose",
"plain_english": "A zero-calorie artificial sweetener.",
"why_added": "To sweeten without adding calories.",
"common_in": ["diet sodas", "sugar-free gum"]
}
],
"detected_signals": {},
"sources_consulted": [],
"notes": []
}
"""
# === C. REAL FOOD LENS (Default - Processing Level) ===
else:
json_data = """
{
"lens": "real_food_score",
"score": 36,
"bar": {
"positive_ratio": 0.0,
"negative_ratio": 1.0
},
"reasons": {
"positives": [],
"concerns": [
"⚠️ Presence of added sugars: sugar, glucose-fructose.",
"⚠️ Contains artificial flavor, a marker of processing.",
"⚠️ Complexity of ingredient list with 13 ingredients."
]
},
"ingredients_breakdown": {
"positive_for_lens": [],
"negative_for_lens": [
"sugars (sugar, glucose-fructose)",
"artificial flavour",
"caramel"
],
"mixed_for_lens": [
"niacinamide",
"pyridoxine hydrochloride (vitamin B6)",
"taurine"
],
"neutral_for_lens": [
"carbonated water",
"citric acid"
]
},
"lab_labels": [
{
"ingredient": "niacinamide",
"plain_english": "A form of vitamin B3.",
"why_added": "Used as a dietary supplement.",
"common_in": ["supplements", "fortified foods"]
}
],
"detected_signals": {},
"sources_consulted": [],
"notes": []
}
"""
return {
"health_analysis": json_data
}
if __name__ == "__main__":
uvicorn.run(app, host="0.0.0.0", port=8000)
How to test this:
Restart your Python server:
Ctrl+Cthenpython -m uvicorn main:app --reload --host 0.0.0.0.Open your App.
Click the "Focus" 🔍 button on the camera screen.
Take a photo.
You should see a Score of 65 and comments about Caffeine/Brain function.
Go back, click "Real Food" 🥗, and take a photo.
You should see a Score of 36 and comments about Processing.
can you create a components for the play button and put it right corner of the screen?
Because I use Elevenlab to return audio file but i want to play on my phone.
This requires three quick steps: updating the Backend to send the audio link, updating the Types to handle it, and creating the Floating Button component.
Step 1: Update backend/main.py (Add Audio URL)
I will add a standard test audio URL to the hardcoded JSON so you can test the button immediately.
Update the JSON in backend/main.py inside the analyze_image function:
# ... inside the "focus" block ...
if lens == "focus":
json_data = """
{
"lens": "focus",
"score": 65,
"audio_url": "https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3",
"bar": { ... }
...
}
"""
# ... do the same for "personal" and "real_food" blocks ...
# You can use the same URL for testing
(Note: In your real ElevenLabs implementation, you will replace this string with the URL or Base64 data you get from their API).
Step 2: Update src/types.ts
Tell TypeScript about the new field.
// src/types.ts
export interface RealFoodAnalysis {
lens: string
score: number
audio_url?: string; // 👈 Add this optional field
bar: {
positive_ratio: number
negative_ratio: number
positive_points?: number
negative_points?: number
}
// ... rest of your types ...
}
Step 3: Create src/components/AudioButton.tsx
This is the new component. It handles playing, pausing, and the cool "speaking" animation.
// src/components/AudioButton.tsx
import { useState, useRef, useEffect } from 'react';
import './AudioButton.css';
interface Props {
url?: string;
}
export default function AudioButton({ url }: Props) {
const [isPlaying, setIsPlaying] = useState(false);
const audioRef = useRef<HTMLAudioElement | null>(null);
useEffect(() => {
if (url) {
audioRef.current = new Audio(url);
audioRef.current.onended = () => setIsPlaying(false);
}
// Cleanup when component unmounts (stop audio)
return () => {
if (audioRef.current) {
audioRef.current.pause();
audioRef.current = null;
}
};
}, [url]);
const toggleAudio = () => {
if (!audioRef.current) return;
if (isPlaying) {
audioRef.current.pause();
} else {
audioRef.current.play();
}
setIsPlaying(!isPlaying);
};
if (!url) return null;
return (
<button
className={`audio-fab ${isPlaying ? 'playing' : ''}`}
onClick={toggleAudio}
>
{isPlaying ? (
<span className="icon">⏸️</span>
) : (
<span className="icon">🔊</span>
)}
{/* Ripple Animation rings */}
{isPlaying && (
<>
<div className="ripple r1"></div>
<div className="ripple r2"></div>
</>
)}
</button>
);
}
Step 4: Add Styles src/components/AudioButton.css
/* src/components/AudioButton.css */
.audio-fab {
position: absolute;
top: 20px;
right: 20px;
width: 50px;
height: 50px;
border-radius: 50%;
background: white;
border: none;
box-shadow: 0 4px 15px rgba(0,0,0,0.15);
display: flex;
justify-content: center;
align-items: center;
font-size: 1.5rem;
cursor: pointer;
z-index: 100; /* On top of everything */
outline: none;
transition: transform 0.2s;
}
.audio-fab:active {
transform: scale(0.9);
}
.audio-fab.playing {
background: #6c5ce7; /* Purple when playing */
color: white;
}
/* Ripple Animation */
.ripple {
position: absolute;
border-radius: 50%;
background: rgba(108, 92, 231, 0.4);
animation: ripple-anim 1.5s infinite;
z-index: -1;
}
.r1 { width: 100%; height: 100%; animation-delay: 0s; }
.r2 { width: 100%; height: 100%; animation-delay: 0.4s; }
@keyframes ripple-anim {
0% { transform: scale(1); opacity: 0.8; }
100% { transform: scale(2); opacity: 0; }
}
Step 5: Add it to AnalysisResult.tsx
Finally, place the button inside your results screen.
// src/components/AnalysisResult.tsx
import AudioButton from './AudioButton'; // 👈 Import it
// ... inside your AnalysisResults component ...
export default function AnalysisResults({ data, loading, error }: Props) {
// ... checks for loading/error ...
if (!data) return null;
const { score, bar, reasons, ingredients_breakdown, lab_labels, audio_url } = data; // 👈 Destructure audio_url
return (
<div className="results-container">
{/* 🔊 THE NEW AUDIO BUTTON */}
{/* We pass the URL from the backend here */}
<AudioButton url={audio_url} />
{/* 1. BIG SCORE CARD */}
<div className="score-card">
{/* ... existing code ... */}
Result: When you scan a product now (using the "Focus" lens if you updated that JSON), a Speaker Icon will appear in the top-right corner of the white results card. Clicking it will play the audio and show a purple pulsing animation.
this is back end
# --- STARTUP EVENT ---
# This runs once when the server starts to set up the Backboard Agent
@app.on_event("startup")
async def startup_event():
global assistant_id, thread_id
print("🤖 Initializing AI Agent...")
# Print out ListModels
# models = genai.list_models()
# print(f"Available models: {[model.name for model in models]}") # Debug print
# Create the Health Expert
assistant = await bb_client.create_assistant(
name="Health Analyzer",
system_prompt="""
SYSTEM / ROLE
You are “Focus Score” evaluator for a consumer ingredient-scanning app. Your job is to score how supportive a product’s ingredients are for steadier cognitive performance (focus/attention/mental energy) using ONLY the ingredient list provided (no nutrition facts unless explicitly included). Be conservative, avoid medical claims, and keep decisions explainable and deterministic.
GOAL
Given an ingredient list, return:
1) A Focus Score from 0–100 (higher = more supportive of steady focus/mental energy)
2) Values to populate a two-part horizontal bar: Supportive vs Risk (POS vs NEG points)
3) “Top reasons” bullets (✅ positives + ⚠️ concerns) that mention specific ingredients
4) Categorize ALL ingredients into 4 groups:
- positive_for_lens
- negative_for_lens
- mixed_for_lens
- neutral_for_lens
5) A list of “Lab Labels”: ingredients that the general public may not recognize, with plain-English explanations of what they are and why they’re added (and brief focus relevance)
6) A short list of 4 broad, credible sources consulted (institution/site) and how used
7) Uncertainty notes if ingredients are ambiguous or info is missing
SAFETY + QUALITY RULES (NON-NEGOTIABLE)
- Do NOT claim an ingredient “causes” ADHD/hyperactivity or cures/enhances intelligence.
- You MAY say: “may contribute to energy spikes/crashes,” “often limited by people seeking steadier focus,” “some people are sensitive,” “evidence is mixed,” etc.
- This lens is NOT medical advice; it is an ingredient-based heuristic.
- Never invent ingredients. Only refer to ingredients present.
- Deterministic: same ingredient list → same output.
LENS DEFINITION (FOCUS / STEADY MENTAL ENERGY)
This lens estimates whether the product’s formulation is likely to support steadier attention and mental energy by:
- penalizing ingredient patterns that commonly signal rapid-digesting sweetness, “spike/crash” profiles, and highly engineered sweet taste
- flagging stimulant markers as context-dependent (can increase alertness for many, but may reduce steadiness via jitters/sleep disruption for some)
- rewarding ingredient patterns commonly described as supportive of brain health and steadier energy (e.g., nuts/seeds, whole grains, omega-3 sources) without overclaiming
- recognizing niche-but-legitimate cognitive-support ingredients if present (e.g., creatine), while stating limitations
BROAD SOURCES (LIMIT TO EXACTLY 4)
Use these as broad reference anchors for cautious wording and general nutrition/brain-health context:
1) Harvard Health Publishing (Harvard Medical School) – general health and nutrition guidance
2) Mayo Clinic – general health and nutrition guidance
3) WebMD – general health information (use cautiously; avoid strong claims)
4) Cleveland Clinic – general health and nutrition guidance
IMPORTANT: Do not quote or cite niche blogs. Use these sources to guide conservative, common-sense explanations.
SCORING MODEL (SIMPLE WEIGHTED POINTS)
Start at BASE_SCORE = 50.
Compute POS_POINTS and NEG_POINTS from criteria below (each criterion adds or subtracts points when triggered).
Final score = clamp( BASE_SCORE + POS_POINTS - NEG_POINTS , 0, 100 ).
Bar visualization uses:
- POS_POINTS (supportive)
- NEG_POINTS (risk)
Ratios:
- positive_ratio = POS_POINTS / (POS_POINTS + NEG_POINTS) if total>0 else 0.5
- negative_ratio = NEG_POINTS / (POS_POINTS + NEG_POINTS) if total>0 else 0.5
CRITERIA + WEIGHTS (INGREDIENT-LIST ONLY)
Apply these rules exactly. If a criterion can’t be determined from ingredients_text, treat it as NOT triggered.
A) NEGATIVE (undermines steady focus / increases spike-crash or overstimulation risk)
N1. Sugar is dominant (18)
Trigger: a sugar term is ingredient #1 or #2
Sugar terms include: sugar, cane sugar, corn syrup, HFCS, dextrose, glucose, fructose, maltose, invert sugar, honey, agave, molasses, syrup, fruit juice concentrate (when used as sweetener)
N2. Multiple added sugars (12)
Trigger: 2+ distinct sugar terms anywhere in list
N3. Artificial colors present (10)
Trigger: Red 40, Yellow 5, Yellow 6, Blue 1, Blue 2, FD&C, “color added”, “artificial color”
Wording constraint: do NOT claim “causes hyperactivity.” Use: “Some people choose to limit artificial colors; evidence varies.”
N4. Stimulant marker present (8) [scored as negative for steadiness]
Trigger: caffeine, guarana, yerba mate, green coffee extract, “energy blend”
Note: Add caveat that effects vary; scoring reflects steadiness and sleep sensitivity concerns.
N5. Non-nutritive sweeteners present (6)
Trigger: aspartame, sucralose, acesulfame K, saccharin, neotame, advantame
stevia/monk fruit: weight 3 (still indicates engineered sweetness)
N6. Highly engineered additive stack (6)
Trigger: 3+ “engineering markers” appear among flavors/emulsifiers/preservatives/isolate starches
Examples: “natural flavors”, polysorbate 80, mono- & diglycerides, carrageenan, xanthan gum, sodium benzoate, potassium sorbate, TBHQ, BHT, modified starch, maltodextrin
B) POSITIVE (supports steadier focus / “brain-supportive” cues without overclaiming)
P1. Nuts/seeds present (10)
Trigger: almonds, walnuts, peanuts, cashews, pistachios, chia, flax, pumpkin seeds, sunflower seeds
P2. Omega-3 source present (10)
Trigger: fish oil, algae oil, DHA, EPA, flax/chia (ALA sources; optional caveat: conversion varies)
P3. Whole grains / slow-carb cue (6)
Trigger: oats, whole oats, whole wheat, brown rice, quinoa, barley, bran
P4. Protein-forward cue (6)
Trigger: eggs, yogurt, milk protein, whey, soy protein, legumes (beans/lentils/chickpeas)
Note: If “protein isolate” appears, you may still count it as protein-forward but classify as mixed_for_lens.
P5. Creatine present (8)
Trigger: creatine, creatine monohydrate
Wording constraint: “Creatine is best known for performance; it has also been studied in cognitive contexts; effects vary.”
P6. No sweeteners (6)
Trigger: no sugar terms AND no non-nutritive sweeteners
DETERMINISTIC EVALUATION STEPS
1) Normalize ingredient list (lowercase, strip punctuation) and split into items.
2) Detect:
- sugar_terms_found + positions
- sweeteners_found
- colors_found
- stimulant_terms_found
- omega3_terms_found
- nuts_seeds_found
- whole_grains_found
- protein_cues_found
- creatine_found
- additive_stack_markers_found (flavors/emulsifiers/preservatives/isolate markers)
3) Apply criteria exactly:
- NEG_POINTS = sum triggered negative criterion weights
- POS_POINTS = sum triggered positive criterion weights
4) Compute final score: clamp(50 + POS_POINTS - NEG_POINTS, 0, 100)
5) Produce top reasons:
- Top 3 POS triggers as ✅ bullets (include ingredient names)
- Top 3 NEG triggers as ⚠️ bullets (include ingredient names + cautious language)
6) Categorize every ingredient into one of:
- positive_for_lens: nuts/seeds, omega-3 sources, whole grains, protein cues, creatine
- negative_for_lens: sugar terms, artificial colors, stimulant markers, non-nutritive sweeteners
- mixed_for_lens: “natural flavors”, “protein isolate”, ambiguous “spices”, certain gums/emulsifiers, caffeine sources (context-dependent), “fruit juice concentrate” (can function as sweetener)
- neutral_for_lens: salt, water, basic acids (citric acid), basic spices if clearly culinary
7) Identify “Lab Labels” deterministically:
- Include ingredients likely unfamiliar to the general public, especially:
a) artificial colors, non-nutritive sweeteners, preservatives, emulsifiers/stabilizers/thickeners, “natural flavors”
b) refined isolates/reconstituted ingredients (e.g., maltodextrin, modified starch, protein isolate)
c) chemical-sounding names (common suffix cues: -ate, -ite, -ide, -ose, -ol, -ium, -phosphate, -carbonate, -chloride)
- Exclude obvious everyday foods: water, salt, sugar, oats, rice, milk, eggs, peanuts, etc. (unless ambiguous)
- For each lab label, provide:
- ingredient (exact as listed)
- plain_english (what it is, in 1 sentence)
- why_added (its function in foods, in 1 sentence)
- focus_relevance (1 short sentence using cautious language)
8) Provide sources_consulted with EXACTLY these four sources and how used.
OUTPUT FORMAT (STRICT JSON ONLY)
Return one JSON object (NOTE: JSON portion must use DOUBLE CURLY BRACES):
{{
"lens": "focus_score",
"score": <int 0-100>,
"bar": {{
"positive_points": <number equal to focus_score>,
"negative_points": <number>,
"positive_ratio": <number 0-1 equal focus_score/100>,
"negative_ratio": <number 0-1 equal focus_score/100>
}},
"reasons": {{
"positives": [ "<✅ ...>", "<✅ ...>", "<✅ ...>" ],
"concerns": [ "<⚠️ ...>", "<⚠️ ...>", "<⚠️ ...>" ]
}},
"criteria_hits": [
{{ "id": "N1", "name": "Sugar is dominant", "direction": "negative", "points": 18, "evidence": "sugar listed as ingredient #1" }}
],
"ingredients_breakdown": {{
"positive_for_lens": [ "<ingredient>", ... ],
"negative_for_lens": [ "<ingredient>", ... ],
"mixed_for_lens": [ "<ingredient>", ... ],
"neutral_for_lens": [ "<ingredient>", ... ]
}},
"lab_labels": [
{{
"ingredient": "<string>",
"plain_english": "<string>",
"why_added": "<string>",
"focus_relevance": "<string>"
}}
],
"detected_signals": {{
"first_ingredient": "<string>",
"ingredient_count": <int>,
"sugars": {{ "terms": [..], "positions": [..] }},
"sweeteners": [..],
"colors": [..],
"stimulants": [..],
"omega3_sources": [..],
"nuts_seeds": [..],
"whole_grains": [..],
"protein_cues": [..],
"creatine": <true/false>,
"additive_stack_markers": [..]
}},
"sources_consulted": [
{{ "source": "Harvard Health Publishing (Harvard Medical School)", "how_used": "Broad nutrition and brain-health framing; supports conservative language on diet patterns and energy." }},
{{ "source": "Mayo Clinic", "how_used": "Broad nutrition and wellness framing; used for cautious, consumer-friendly explanations." }},
{{ "source": "WebMD", "how_used": "General health reference for plain-language descriptions; do not use for strong causal claims." }},
{{ "source": "Cleveland Clinic", "how_used": "Broad clinical health guidance framing; supports cautious explanations about sugar, stimulants, and diet quality." }}
],
"notes": [ "<uncertainty/caveats>" ]
}}
USER INPUT
ingredients_text: "<string>"
Now evaluate the provided ingredients_text according to this Focus Score lens and return STRICT JSON ONLY (using the DOUBLE CURLY BRACES structure above).
"""
)
assistant_id = assistant.assistant_id
# Create a conversation thread
thread = await bb_client.create_thread(assistant_id)
thread_id = thread.thread_id
print("✅ Agent Ready!")
@app.post("/api/analyze")
async def analyze_image(file: UploadFile = File(...)):
# --- STEP 1: READ IMAGE ---
print("📸 Receiving image...")
contents = await file.read()
image = Image.open(io.BytesIO(contents))
# --- STEP 2: GEMINI (EXTRACT TEXT) ---
print("👀 Gemini is reading the label...")
ocr_response = vision_model.generate_content([
"Extract list of ingredients from the text. Output the list only. Make it fast",
image
])
extracted_text = ocr_response.text
print(f"📝 Extracted Text: {extracted_text[:50]}...") # Print first 50 chars
# --- STEP 3: BACKBOARD (ANALYZE HEALTH) ---
print("🧠 Backboard is analyzing health...")
# We send the text from Gemini into Backboard
analysis_response = await bb_client.add_message(
thread_id=thread_id,
content=f"Here are the ingredients I found: {extracted_text}. Return ONLY valid JSON.",
stream=False # Wait for the full answer
)
# --- STEP 4: CLEAN THE JSON STRING---
raw_content = analysis_response.content
# Remove markdown code blocks if they exist
clean_json = raw_content.replace("```json", "").replace("```", "").strip()
print(f"cleaned json: {clean_json}") # Debug print to see what is happening
# --- STEP 5: GENERATE SUMMARY TEXT ---
print("✍️ Writing summary...")
summary_prompt = f"""
Read this analysis and write a 1-sentence, friendly summary for a user.
Focus on the score and the biggest pro or con. Keep it conversational.
Analysis: {clean_json}
"""
summary_response = vision_model.generate_content(summary_prompt)
narrative_text = summary_response.text.strip()
# --- STEP 6: ELEVENLABS AUDIO GENERATION ---
print("🎙️ Generating high-quality audio...")
try:
# Generate audio (returns a generator of bytes)
audio_generator = elevenlabs.text_to_speech.convert(
voice_id="JBFqnCBsd6RMkjVDRZzb", # "Adam" (deep, calm male voice)
model_id="eleven_turbo_v2_5", # Low latency model
text=narrative_text
)
# Consume the generator to get full audio bytes
audio_bytes = b"".join(audio_generator)
# Encode to Base64
audio_b64 = base64.b64encode(audio_bytes).decode('utf-8')
except Exception as e:
print(f"❌ Audio generation failed: {e}")
audio_b64 = None
# --- STEP 5: RETURN TO REACT ---
return {
"health_analysis": clean_json, # The full JSON analysis for detailed display
"narrative_text": narrative_text,
"audio_base64": audio_b64 # Frontend can play this directly
}
This backend code looks great! It generates the audio using ElevenLabs and sends it to the frontend as a Base64 string (audio_base64), separate from the JSON analysis.
To make the Play Button work, we need to update your CameraCapture.tsx to take that Base64 string, convert it into a playable URL, and inject it into the data object so the button can find it.
Step 1: Update src/components/CameraCapture.tsx
This version connects to your real backend, parses the separate audio field, and combines it with the analysis.
// src/components/CameraCapture.tsx
import { useState, type ChangeEvent } from 'react'
import './CameraCapture.css'
import { type RealFoodAnalysis } from '../types'
import AnalysisResults from './AnalysisResult'
// Define the available lenses
type LensType = 'focus' | 'real_food' | 'personal';
export default function CameraCapture() {
const [image, setImage] = useState<string | null>(null)
const [realFoodAnalysis, setRealFoodAnalysis] = useState<RealFoodAnalysis | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string>("")
const [selectedLens, setSelectedLens] = useState<LensType>('real_food')
const handleImageChange = async (e: ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files[0]) {
const file = e.target.files[0]
const url = URL.createObjectURL(file)
setImage(url)
setRealFoodAnalysis(null)
setError("")
setLoading(true)
const formData = new FormData()
formData.append("file", file)
formData.append("lens", selectedLens)
try {
// ⚠️ Point to your REAL backend endpoint now
// Change URL if using ngrok (e.g., "https://xyz.ngrok-free.app/api/analyze")
const response = await fetch("http://10.189.4.149:8000/api/analyze", {
method: "POST",
body: formData,
})
const data = await response.json()
// 1. Parse the text analysis (it comes as a string)
let parsedData;
try {
parsedData = typeof data.health_analysis === 'string'
? JSON.parse(data.health_analysis)
: data.health_analysis;
} catch (e) {
console.error("JSON Parse error:", e);
parsedData = null;
}
if (parsedData) {
// 2. 🎵 INJECT AUDIO: Convert Base64 to a playable Data URL
if (data.audio_base64) {
// This creates a format the browser can play directly
parsedData.audio_url = `data:audio/mpeg;base64,${data.audio_base64}`;
}
setRealFoodAnalysis(parsedData)
} else {
setError("Could not read ingredients format.")
}
} catch (err) {
console.error(err);
setError("Error: Could not reach the brain! 🧠❌")
} finally {
setLoading(false)
}
}
}
const hasResults = realFoodAnalysis !== null || loading;
return (
<div className="camera-container">
<div className={`sliding-wrapper ${hasResults ? 'show-results' : 'show-camera'}`}>
{/* LEFT SIDE: Camera */}
<div className="image-area">
{!image && (
<div className="lens-overlay">
<button
className={`lens-btn ${selectedLens === 'focus' ? 'active' : ''}`}
onClick={() => setSelectedLens('focus')}
>
<span className="lens-icon">🔍</span>
<span className="lens-label">Focus</span>
</button>
<button
className={`lens-btn ${selectedLens === 'real_food' ? 'active' : ''}`}
onClick={() => setSelectedLens('real_food')}
>
<span className="lens-icon">🥗</span>
<span className="lens-label">Real Food</span>
</button>
<button
className={`lens-btn ${selectedLens === 'personal' ? 'active' : ''}`}
onClick={() => setSelectedLens('personal')}
>
<span className="lens-icon">👤</span>
<span className="lens-label">Personal</span>
</button>
</div>
)}
{image ? (
<img src={image} alt="Captured" className="photo-preview" />
) : (
<div className="placeholder">
<div style={{fontSize: '3rem', marginBottom: '10px'}}>📸</div>
<p>Take a photo</p>
</div>
)}
</div>
{/* RIGHT SIDE: Results */}
<div className="results-list">
<AnalysisResults
data={realFoodAnalysis}
loading={loading}
error={error}
/>
</div>
</div>
<div className="controls-area">
<input
type="file"
accept="image/*"
capture="environment"
onChange={handleImageChange}
id="cameraInput"
style={{ display: 'none' }}
/>
<label htmlFor="cameraInput" className="camera-button">
{loading ? "Scanning..." : (realFoodAnalysis ? "📸 Scan New" : "📸 Take Photo")}
</label>
</div>
</div>
)
}
Step 2: Double Check src/types.ts
Since we are injecting audio_url manually in the frontend, make sure your TypeScript interface allows it (as discussed in the previous step):
export interface RealFoodAnalysis {
lens: string
score: number
audio_url?: string; // 👈 This MUST be here
// ... rest of types
}
Step 3: Run It!
Start Backend: Ensure your Python server has
elevenlabsinstalled (pip install elevenlabs) and the API key set.Start Frontend:
npm run dev -- --hostTest: Click "Focus", take a picture.
Result: When the analysis loads, the Play Button (from
AudioButton.tsx) will appear in the top right. Clicking it will play the voice generated by ElevenLabs!
but i add "audio_base64": audio_b64 # Frontend can play this directly
That is perfect! Since you are receiving the raw base64 string directly from your backend, you don't need to mess with separate URLs. We can play that string directly in the browser.
Here is the complete solution to create the Floating Play Button and put it in the top-right corner.
Step 1: Create src/components/AudioButton.tsx
This component takes the base64 string, converts it to a playable sound on the fly, and handles the play/pause logic.
// src/components/AudioButton.tsx
import { useState, useRef, useEffect } from 'react';
import './AudioButton.css';
interface Props {
base64Audio?: string; // We accept the raw string
}
export default function AudioButton({ base64Audio }: Props) {
const [isPlaying, setIsPlaying] = useState(false);
const audioRef = useRef<HTMLAudioElement | null>(null);
// Whenever the audio data changes, setup the player
useEffect(() => {
if (base64Audio) {
// Create a playable source directly from base64
const audioSrc = `data:audio/mpeg;base64,${base64Audio}`;
audioRef.current = new Audio(audioSrc);
// When audio finishes, reset the button icon
audioRef.current.onended = () => setIsPlaying(false);
}
// Cleanup: Stop audio if user leaves the screen
return () => {
if (audioRef.current) {
audioRef.current.pause();
audioRef.current = null;
}
};
}, [base64Audio]);
const toggleAudio = () => {
if (!audioRef.current) return;
if (isPlaying) {
audioRef.current.pause();
} else {
audioRef.current.play().catch(e => console.error("Playback failed:", e));
}
setIsPlaying(!isPlaying);
};
// Don't render anything if there is no audio
if (!base64Audio) return null;
return (
<button
className={`audio-fab ${isPlaying ? 'playing' : ''}`}
onClick={toggleAudio}
aria-label="Play Analysis"
>
{isPlaying ? (
<span className="icon">⏸️</span>
) : (
<span className="icon">🔊</span>
)}
{/* Cool Ripple Effect when playing */}
{isPlaying && (
<>
<div className="ripple r1"></div>
<div className="ripple r2"></div>
</>
)}
</button>
);
}
Step 2: Create src/components/AudioButton.css
This handles the positioning (Top Right) and the animation.
/* src/components/AudioButton.css */
.audio-fab {
/* ⚠️ FLOATING POSITIONING */
position: absolute;
top: 20px;
right: 20px;
z-index: 100; /* Sit on top of the results card */
/* Button Styling */
width: 50px;
height: 50px;
border-radius: 50%;
background: white;
border: none;
box-shadow: 0 4px 15px rgba(0,0,0,0.15);
display: flex;
justify-content: center;
align-items: center;
font-size: 1.5rem;
cursor: pointer;
outline: none;
transition: transform 0.2s, background 0.3s;
}
.audio-fab:active {
transform: scale(0.9);
}
.audio-fab.playing {
background: #6c5ce7; /* Purple when active */
color: white;
}
/* --- RIPPLE ANIMATION --- */
.ripple {
position: absolute;
border-radius: 50%;
background: rgba(108, 92, 231, 0.4);
animation: ripple-anim 1.5s infinite;
z-index: -1; /* Behind the button */
}
.r1 { width: 100%; height: 100%; animation-delay: 0s; }
.r2 { width: 100%; height: 100%; animation-delay: 0.4s; }
@keyframes ripple-anim {
0% { transform: scale(1); opacity: 0.8; }
100% { transform: scale(2.5); opacity: 0; }
}
Step 3: Update src/types.ts
Add the audio_base64 field so TypeScript knows it exists.
// src/types.ts
export interface RealFoodAnalysis {
// ... your existing fields ...
lens: string
score: number
// ✅ Add this line:
audio_base64?: string;
// ... rest of interface
}
Step 4: Inject it in CameraCapture.tsx
When you receive the data from the backend, health_analysis is one part, and audio_base64 is another. We need to merge them into one object before saving it to state.
// src/components/CameraCapture.tsx
// ... inside handleImageChange ...
const response = await fetch("...", { ... });
const data = await response.json();
// 1. Parse the JSON analysis
let parsedAnalysis = JSON.parse(data.health_analysis);
// 2. 💉 INJECT THE AUDIO
// We add the base64 string directly into the object we pass to the results component
if (data.audio_base64) {
parsedAnalysis.audio_base64 = data.audio_base64;
}
setRealFoodAnalysis(parsedAnalysis);
Step 5: Render in AnalysisResults.tsx
Finally, place the button inside your results container.
// src/components/AnalysisResult.tsx
import AudioButton from './AudioButton'; // Import the button
export default function AnalysisResults({ data, loading, error }: Props) {
// ... checks ...
if (!data) return null;
// Destructure audio_base64 from data
const { score, bar, reasons, ingredients_breakdown, lab_labels, audio_base64 } = data;
return (
<div className="results-container">
{/* 🔊 THE FLOATING BUTTON */}
{/* It will position itself in the top-right automatically via CSS */}
<AudioButton base64Audio={audio_base64} />
{/* 1. BIG SCORE CARD */}
<div className="score-card">
{/* ... rest of your code ... */}
Cannot redeclare block-scoped variable 'score'
This error happens because you likely pasted the new line of code (destructuring audio_base64) without removing the old one. In JavaScript/TypeScript, you can only declare const score once inside a function.
To fix it, merge everything into a single line.
Open src/components/AnalysisResult.tsx
Find where you are extracting data from data and ensure it looks exactly like this (one single const statement):
// ❌ WRONG (Don't do this)
// const { score, bar, reasons } = data;
// const { score, audio_base64 } = data; // Error! 'score' is already declared
// ✅ CORRECT (Do this)
const {
score,
bar,
reasons,
ingredients_breakdown,
lab_labels,
audio_base64 // Add the new field here in the same list
} = data;
Full Component Example
Here is the clean, error-free start of your component:
export default function AnalysisResults({ data, loading, error }: Props) {
// ... (keep helper functions like getScoreColor) ...
if (loading) return ( ... );
if (error) return ( ... );
if (!data) return null;
// 👇 ONE SINGLE DESTRUCTURING LINE
const {
score,
bar,
reasons,
ingredients_breakdown,
lab_labels,
audio_base64
} = data;
return (
<div className="results-container">
{/* Pass the audio to the button */}
<AudioButton base64Audio={audio_base64} />
{/* Use score normally */}
<div className="score-card">...</div>
</div>
)
}
it's still the same
// src/components/AnalysisResult.tsx
import './AnalysisResult.css'
import type { RealFoodAnalysis } from '../types'
import AudioButton from './AudioButton'
interface Props {
data: RealFoodAnalysis | null
loading: boolean
error: string
}
export default function AnalysisResults({ data, loading, error }: Props) {
if (!data && !loading && !error) return null; // Don't render anything if we have no data and we're not loading or in error state
const { score, bar, reasons, ingredients_breakdown, lab_labels, audio_base64 } = data;
// Helper for Score Color
const getScoreColor = (score: number) => {
if (score >= 80) return '#2ecc71'; // Green
if (score >= 50) return '#f1c40f'; // Yellow
return '#e74c3c'; // Red
}
// Loading State
if (loading) return (
<div className="results-container loading">
<div className="spinner">🧠</div>
<p>Analyzing Real Food Score...</p>
</div>
)
// Error State
if (error) return <div className="results-container error"><p>⚠️ {error}</p></div>
// Empty State
if (!data) return null;
// Destructure the new data structure
const {
score,
bar,
reasons,
ingredients_breakdown,
lab_labels,
audio_base64 // Add the new field here in the same list
} = data;
return (
<div className="results-container">
{/* AUDIO BUTTON */}
<AudioButton base64Audio={audio_base64} />
{/* 1. BIG SCORE CARD */}
<div className="score-card">
<h3 className="card-title">{lens}</h3>
<div className="score-circle" style={{ borderColor: getScoreColor(score) }}>
<span className="score-number" style={{ color: getScoreColor(score) }}>
{score}
</span>
<span className="score-label">/ 100</span>
</div>
</div>
{/* 2. THE VISUAL BAR (Positive vs Negative) */}
<div className="bar-section">
<div className="bar-labels">
<span style={{color: '#2ecc71'}}>Postive</span>
<span style={{color: '#e74c3c'}}>Negative</span>
</div>
<div className="progress-bar-container">
{/* Green Bar (Positive) */}
<div
className="bar-segment positive"
style={{ flex: ingredients_breakdown.positive_for_lens.length || 0.05 }} // Min width for visibility
/>
{/* Spacer (Neutral) */}
<div
className="bar-segment neutral"
style={{ flex: ingredients_breakdown.neutral_for_lens.length || 0.05 }}
/>
{/* Red Bar (Negative) */}
<div
className="bar-segment negative"
style={{ flex: ingredients_breakdown.negative_for_lens.length || 0.05 }}
/>
</div>
</div>
{/* 3. TOP REASONS (Bullet Points) */}
<div className="reasons-section">
{reasons.positives.length > 0 && (
<div className="reason-box good">
<h4>✅ Why it's good</h4>
<ul>
{reasons.positives.map((r, i) => <li key={i}>{r}</li>)}
</ul>
</div>
)}
{reasons.concerns.length > 0 && (
<div className="reason-box bad">
<h4>⚠️ Concerns</h4>
<ul>
{reasons.concerns.map((r, i) => <li key={i}>{r}</li>)}
</ul>
</div>
)}
</div>
{/* 4. INGREDIENT BREAKDOWN (Dropdowns) */}
<h3 className="section-header">Ingredient Breakdown</h3>
{/* B. Helpful Ingredients (positive_for_lens) */}
<IngredientDropdown
title="🥗 Whole Foods / Helpful"
items={ingredients_breakdown.positive_for_lens}
color="green"
/>
{/* C. Mixed / Complex (mixed_for_lens) */}
<IngredientDropdown
title="🤷 Mixed / Complex"
items={ingredients_breakdown.mixed_for_lens}
color="orange"
/>
{/* D. Neutral (neutral_for_lens) */}
<IngredientDropdown
title="🧂 Neutral"
items={ingredients_breakdown.neutral_for_lens}
color="grey"
/>
{/* A. Concerning Ingredients (negative_for_lens) */}
<IngredientDropdown
title="⚠️ Concerning"
items={ingredients_breakdown.negative_for_lens}
color="red"
isOpen={true} // Default open
/>
{/* 5. LAB LABELS (New Section) */}
{lab_labels && lab_labels.length > 0 && (
<div className="lab-labels-section">
<h3 className="section-header">📚 Lab Labels</h3>
<div className="lab-labels-list">
{lab_labels.map((label, i) => (
<div key={i} className="lab-card">
<div className="lab-name">{label.ingredient}</div>
<div className="lab-desc">{label.plain_english}</div>
<div className="lab-meta">
<strong>Why added:</strong> {label.why_added}
</div>
</div>
))}
</div>
</div>
)}
</div>
)
}
// Mini Component for the Dropdowns
function IngredientDropdown({ title, items, color, isOpen = false }: any) {
if (!items || items.length === 0) return null;
return (
<details className={`ing-dropdown ${color}`} open={isOpen}>
<summary>
{title}
<span className="count-badge">{items.length}</span>
</summary>
<div className="dropdown-content">
{items.map((item: string, i: number) => (
<div key={i} className="ing-chip">{item}</div>
))}
</div>
</details>
)
}
The error is happening because you are declaring score (and the other variables) twice in the same function.
At the very top, you have:
const { score... } = data;Further down, you have it again:
const { score... } = data;
You also have a usage of {lens} in your JSX (<h3 className="card-title">{lens}</h3>), but you forgot to extract lens from the data.
Here is the fixed, clean code. Copy this entirely to replace your src/components/AnalysisResult.tsx.
// src/components/AnalysisResult.tsx
import './AnalysisResult.css'
import type { RealFoodAnalysis } from '../types'
import AudioButton from './AudioButton'
interface Props {
data: RealFoodAnalysis | null
loading: boolean
error: string
}
export default function AnalysisResults({ data, loading, error }: Props) {
// 1. Helper for Score Color
const getScoreColor = (score: number) => {
if (score >= 80) return '#2ecc71'; // Green
if (score >= 50) return '#f1c40f'; // Yellow
return '#e74c3c'; // Red
}
// 2. Loading State
if (loading) return (
<div className="results-container loading">
<div className="spinner">🧠</div>
<p>Analyzing...</p>
</div>
)
// 3. Error State
if (error) return <div className="results-container error"><p>⚠️ {error}</p></div>
// 4. Empty State
if (!data) return null;
// 5. DESTRUCTURE DATA (Only do this ONCE, right here)
const {
lens, // <--- Added 'lens' so the title works
score,
reasons,
ingredients_breakdown,
lab_labels,
audio_base64
} = data;
return (
<div className="results-container">
{/* 🔊 AUDIO BUTTON (Floating) */}
<AudioButton base64Audio={audio_base64} />
{/* 1. BIG SCORE CARD */}
<div className="score-card">
{/* Display the lens name (e.g. "Focus", "Real Food") */}
<h3 className="card-title">{lens?.replace('_', ' ') || 'Analysis'}</h3>
<div className="score-circle" style={{ borderColor: getScoreColor(score) }}>
<span className="score-number" style={{ color: getScoreColor(score) }}>
{score}
</span>
<span className="score-label">/ 100</span>
</div>
</div>
{/* 2. THE VISUAL BAR */}
<div className="bar-section">
<div className="bar-labels">
<span style={{color: '#2ecc71'}}>Positive</span>
<span style={{color: '#e74c3c'}}>Concerns</span>
</div>
<div className="progress-bar-container">
{/* Green Bar (Positive) */}
<div
className="bar-segment positive"
style={{ flex: ingredients_breakdown.positive_for_lens.length || 0.1 }}
/>
{/* Spacer (Neutral/Mixed) */}
<div
className="bar-segment neutral"
style={{ flex: (ingredients_breakdown.neutral_for_lens.length + ingredients_breakdown.mixed_for_lens.length) || 0.1, background: '#eee' }}
/>
{/* Red Bar (Negative) */}
<div
className="bar-segment negative"
style={{ flex: ingredients_breakdown.negative_for_lens.length || 0.1 }}
/>
</div>
</div>
{/* 3. TOP REASONS */}
<div className="reasons-section">
{reasons.positives.length > 0 && (
<div className="reason-box good">
<h4>✅ Why it's good</h4>
<ul>
{reasons.positives.map((r, i) => <li key={i}>{r}</li>)}
</ul>
</div>
)}
{reasons.concerns.length > 0 && (
<div className="reason-box bad">
<h4>⚠️ Concerns</h4>
<ul>
{reasons.concerns.map((r, i) => <li key={i}>{r}</li>)}
</ul>
</div>
)}
</div>
{/* 4. INGREDIENT BREAKDOWN */}
<h3 className="section-header">Ingredient Breakdown</h3>
<IngredientDropdown
title="🥗 Whole Foods / Helpful"
items={ingredients_breakdown.positive_for_lens}
color="green"
/>
<IngredientDropdown
title="🤷 Mixed / Complex"
items={ingredients_breakdown.mixed_for_lens}
color="orange"
/>
<IngredientDropdown
title="🧂 Neutral"
items={ingredients_breakdown.neutral_for_lens}
color="grey"
/>
<IngredientDropdown
title="⚠️ Concerning"
items={ingredients_breakdown.negative_for_lens}
color="red"
isOpen={true}
/>
{/* 5. LAB LABELS */}
{lab_labels && lab_labels.length > 0 && (
<div className="lab-labels-section">
<h3 className="section-header">📚 Lab Labels</h3>
<div className="lab-labels-list">
{lab_labels.map((label, i) => (
<div key={i} className="lab-card">
<div className="lab-name">{label.ingredient}</div>
<div className="lab-desc">{label.plain_english}</div>
<div className="lab-meta">
<strong>Why added:</strong> {label.why_added}
</div>
</div>
))}
</div>
</div>
)}
</div>
)
}
// Mini Component for the Dropdowns
function IngredientDropdown({ title, items, color, isOpen = false }: any) {
if (!items || items.length === 0) return null;
return (
<details className={`ing-dropdown ${color}`} open={isOpen}>
<summary>
{title}
<span className="count-badge">{items.length}</span>
</summary>
<div className="dropdown-content">
{items.map((item: string, i: number) => (
<div key={i} className="ing-chip">{item}</div>
))}
</div>
</details>
)
}
the same
// src/components/AnalysisResult.tsx
import './AnalysisResult.css'
import type { RealFoodAnalysis } from '../types'
import AudioButton from './AudioButton'
interface Props {
data: RealFoodAnalysis | null
loading: boolean
error: string
}
export default function AnalysisResults({ data, loading, error }: Props) {
const { score, bar, reasons, ingredients_breakdown, lab_labels, audio_base64 } = data;
// Helper for Score Color
const getScoreColor = (score: number) => {
if (score >= 80) return '#2ecc71'; // Green
if (score >= 50) return '#f1c40f'; // Yellow
return '#e74c3c'; // Red
}
// Loading State
if (loading) return (
<div className="results-container loading">
<div className="spinner">🧠</div>
<p>Analyzing Real Food Score...</p>
</div>
)
// Error State
if (error) return <div className="results-container error"><p>⚠️ {error}</p></div>
// Empty State
if (!data) return null;
return (
<div className="results-container">
{/* AUDIO BUTTON */}
<AudioButton base64Audio={audio_base64} />
{/* 1. BIG SCORE CARD */}
<div className="score-card">
<h3 className="card-title">{lens}</h3>
<div className="score-circle" style={{ borderColor: getScoreColor(score) }}>
<span className="score-number" style={{ color: getScoreColor(score) }}>
{score}
</span>
<span className="score-label">/ 100</span>
</div>
</div>
{/* 2. THE VISUAL BAR (Positive vs Negative) */}
<div className="bar-section">
<div className="bar-labels">
<span style={{color: '#2ecc71'}}>Postive</span>
<span style={{color: '#e74c3c'}}>Negative</span>
</div>
<div className="progress-bar-container">
{/* Green Bar (Positive) */}
<div
className="bar-segment positive"
style={{ flex: ingredients_breakdown.positive_for_lens.length || 0.05 }} // Min width for visibility
/>
{/* Spacer (Neutral) */}
<div
className="bar-segment neutral"
style={{ flex: ingredients_breakdown.neutral_for_lens.length || 0.05 }}
/>
{/* Red Bar (Negative) */}
<div
className="bar-segment negative"
style={{ flex: ingredients_breakdown.negative_for_lens.length || 0.05 }}
/>
</div>
</div>
{/* 3. TOP REASONS (Bullet Points) */}
<div className="reasons-section">
{reasons.positives.length > 0 && (
<div className="reason-box good">
<h4>✅ Why it's good</h4>
<ul>
{reasons.positives.map((r, i) => <li key={i}>{r}</li>)}
</ul>
</div>
)}
{reasons.concerns.length > 0 && (
<div className="reason-box bad">
<h4>⚠️ Concerns</h4>
<ul>
{reasons.concerns.map((r, i) => <li key={i}>{r}</li>)}
</ul>
</div>
)}
</div>
{/* 4. INGREDIENT BREAKDOWN (Dropdowns) */}
<h3 className="section-header">Ingredient Breakdown</h3>
{/* B. Helpful Ingredients (positive_for_lens) */}
<IngredientDropdown
title="🥗 Whole Foods / Helpful"
items={ingredients_breakdown.positive_for_lens}
color="green"
/>
{/* C. Mixed / Complex (mixed_for_lens) */}
<IngredientDropdown
title="🤷 Mixed / Complex"
items={ingredients_breakdown.mixed_for_lens}
color="orange"
/>
{/* D. Neutral (neutral_for_lens) */}
<IngredientDropdown
title="🧂 Neutral"
items={ingredients_breakdown.neutral_for_lens}
color="grey"
/>
{/* A. Concerning Ingredients (negative_for_lens) */}
<IngredientDropdown
title="⚠️ Concerning"
items={ingredients_breakdown.negative_for_lens}
color="red"
isOpen={true} // Default open
/>
{/* 5. LAB LABELS (New Section) */}
{lab_labels && lab_labels.length > 0 && (
<div className="lab-labels-section">
<h3 className="section-header">📚 Lab Labels</h3>
<div className="lab-labels-list">
{lab_labels.map((label, i) => (
<div key={i} className="lab-card">
<div className="lab-name">{label.ingredient}</div>
<div className="lab-desc">{label.plain_english}</div>
<div className="lab-meta">
<strong>Why added:</strong> {label.why_added}
</div>
</div>
))}
</div>
</div>
)}
</div>
)
}
// Mini Component for the Dropdowns
function IngredientDropdown({ title, items, color, isOpen = false }: any) {
if (!items || items.length === 0) return null;
return (
<details className={`ing-dropdown ${color}`} open={isOpen}>
<summary>
{title}
<span className="count-badge">{items.length}</span>
</summary>
<div className="dropdown-content">
{items.map((item: string, i: number) => (
<div key={i} className="ing-chip">{item}</div>
))}
</div>
</details>
)
}
The error happens because you are trying to extract variables (destructure) from data at the very top of the function, before checking if data actually exists.
When the app first loads, data is null. So the line const { score... } = data tries to read from null and crashes immediately.
You must move that line DOWN, after the loading/error checks.
Here is the corrected code:
// src/components/AnalysisResult.tsx
import './AnalysisResult.css'
import type { RealFoodAnalysis } from '../types'
import AudioButton from './AudioButton'
interface Props {
data: RealFoodAnalysis | null
loading: boolean
error: string
}
export default function AnalysisResults({ data, loading, error }: Props) {
// 1. Helper for Score Color
const getScoreColor = (score: number) => {
if (score >= 80) return '#2ecc71'; // Green
if (score >= 50) return '#f1c40f'; // Yellow
return '#e74c3c'; // Red
}
// 2. Loading State
if (loading) return (
<div className="results-container loading">
<div className="spinner">🧠</div>
<p>Analyzing...</p>
</div>
)
// 3. Error State
if (error) return <div className="results-container error"><p>⚠️ {error}</p></div>
// 4. Empty State
if (!data) return null;
// ⚠️ MOVED DOWN HERE! (Safe to run now because we checked !data above)
const {
lens,
score,
bar,
reasons,
ingredients_breakdown,
lab_labels,
audio_base64
} = data;
return (
<div className="results-container">
{/* AUDIO BUTTON */}
<AudioButton base64Audio={audio_base64} />
{/* 1. BIG SCORE CARD */}
<div className="score-card">
{/* We use the lens name here */}
<h3 className="card-title">{lens?.replace('_', ' ') || "Analysis"}</h3>
<div className="score-circle" style={{ borderColor: getScoreColor(score) }}>
<span className="score-number" style={{ color: getScoreColor(score) }}>
{score}
</span>
<span className="score-label">/ 100</span>
</div>
</div>
{/* 2. THE VISUAL BAR (Positive vs Negative) */}
<div className="bar-section">
<div className="bar-labels">
<span style={{color: '#2ecc71'}}>Positive</span>
<span style={{color: '#e74c3c'}}>Concerns</span>
</div>
<div className="progress-bar-container">
{/* Green Bar */}
<div
className="bar-segment positive"
style={{ flex: ingredients_breakdown.positive_for_lens.length || 0.1 }}
/>
{/* Spacer (Neutral) */}
<div
className="bar-segment neutral"
style={{ flex: (ingredients_breakdown.neutral_for_lens.length + ingredients_breakdown.mixed_for_lens.length) || 0.1, background: '#eee' }}
/>
{/* Red Bar */}
<div
className="bar-segment negative"
style={{ flex: ingredients_breakdown.negative_for_lens.length || 0.1 }}
/>
</div>
</div>
{/* 3. TOP REASONS */}
<div className="reasons-section">
{reasons.positives.length > 0 && (
<div className="reason-box good">
<h4>✅ Why it's good</h4>
<ul>
{reasons.positives.map((r, i) => <li key={i}>{r}</li>)}
</ul>
</div>
)}
{reasons.concerns.length > 0 && (
<div className="reason-box bad">
<h4>⚠️ Concerns</h4>
<ul>
{reasons.concerns.map((r, i) => <li key={i}>{r}</li>)}
</ul>
</div>
)}
</div>
{/* 4. INGREDIENT BREAKDOWN */}
<h3 className="section-header">Ingredient Breakdown</h3>
<IngredientDropdown
title="🥗 Whole Foods / Helpful"
items={ingredients_breakdown.positive_for_lens}
color="green"
/>
<IngredientDropdown
title="🤷 Mixed / Complex"
items={ingredients_breakdown.mixed_for_lens}
color="orange"
/>
<IngredientDropdown
title="🧂 Neutral"
items={ingredients_breakdown.neutral_for_lens}
color="grey"
/>
<IngredientDropdown
title="⚠️ Concerning"
items={ingredients_breakdown.negative_for_lens}
color="red"
isOpen={true}
/>
{/* 5. LAB LABELS */}
{lab_labels && lab_labels.length > 0 && (
<div className="lab-labels-section">
<h3 className="section-header">📚 Lab Labels</h3>
<div className="lab-labels-list">
{lab_labels.map((label, i) => (
<div key={i} className="lab-card">
<div className="lab-name">{label.ingredient}</div>
<div className="lab-desc">{label.plain_english}</div>
<div className="lab-meta">
<strong>Why added:</strong> {label.why_added}
</div>
</div>
))}
</div>
</div>
)}
</div>
)
}
// Mini Component for the Dropdowns
function IngredientDropdown({ title, items, color, isOpen = false }: any) {
if (!items || items.length === 0) return null;
return (
<details className={`ing-dropdown ${color}`} open={isOpen}>
<summary>
{title}
<span className="count-badge">{items.length}</span>
</summary>
<div className="dropdown-content">
{items.map((item: string, i: number) => (
<div key={i} className="ing-chip">{item}</div>
))}
</div>
</details>
)
}
I can't see AudioButton
The most likely reason you cannot see the button is that your Hardcoded Backend is sending audio_url, but your Frontend is now strictly looking for audio_base64. Since audio_base64 is missing/null in your hardcoded data, the component returns null (invisible).
Here is the fix: Update your Hardcoded Backend to send a valid dummy sound so the button appears.
1. Update backend/main.py (Hardcoded Endpoint)
I have generated a short "Beep" sound in Base64 so you can test the button immediately without using ElevenLabs credits.
Replace your analyze_image hardcoded function with this:
@app.post("/api/analyzehardcoded")
async def analyze_image(file: UploadFile = File(...), lens: str = Form(...)):
print(f"📸 Receiving image for lens: {lens}")
await file.read()
# A short "Ding" sound in Base64 for testing
dummy_audio = "SUQzBAAAAAABAFRYWFgAAAASAAADbWFqb3JfYnJhbmQAbXA0MgBUWFhYAAAAEQAAA21pbm9yX3ZlcnNpb24AMABUWFhYAAAAHAAAA2NvbXBhdGlibGVfYnJhbmRzAGlzb21tcDQyAFRTU0UAAAAPAAADTGF2ZjU4LjQ1LjEwMAAAAAAAAAAAAAAA//uQZAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAASW5mbwAAAA8AAAAEAABdwgADBQgLDA0PEhUXGh0fIiUmKCsuMTM2OTxBQ0VIS01QUlVYW11gYmZqa2xvcnN2eXt+gYOFiYuOkJKVmJqcn6KjpKalqKyvsLKztri7vsHDxcjJzM/S1NXX2drd4OHj5ebn6Onr7e/w8vP2+Pv9AAAAADBMYXZjNTguNTQuMTAwAAAAAAAAAAAAAAH8AAAABmAAAX3CJD8WAAAAAAAAAAAAAAAAAAAA//uQZAAP8AAAaQAAAAgAAA0gAAABAAABpAAAACAAADSAAAAEAAIMWluZyB0aGVseSBhbmQgcHJvZHVjZWQgdGhpcyBmaWxlLg=="
# ... Your JSON logic ...
if lens == "focus":
# We inject "audio_base64" here!
json_data = f"""
{{
"lens": "focus",
"score": 65,
"audio_base64": "{dummy_audio}",
"bar": {{
"positive_ratio": 0.6,
"negative_ratio": 0.4
}},
"reasons": {{
"positives": ["✅ Contains Caffeine.", "✅ Vitamin B12."],
"concerns": ["⚠️ High Sugar."]
}},
"ingredients_breakdown": {{
"positive_for_lens": ["caffeine"],
"negative_for_lens": ["sugar"],
"mixed_for_lens": [],
"neutral_for_lens": ["water"]
}},
"lab_labels": [],
"detected_signals": {{}},
"sources_consulted": [],
"notes": []
}}
"""
else:
# Default fallback
json_data = f"""
{{
"lens": "real_food_score",
"score": 36,
"audio_base64": "{dummy_audio}",
"bar": {{ "positive_ratio": 0.2, "negative_ratio": 0.8 }},
"reasons": {{ "positives": [], "concerns": ["⚠️ Processing markers."] }},
"ingredients_breakdown": {{
"positive_for_lens": [],
"negative_for_lens": ["sugar"],
"mixed_for_lens": [],
"neutral_for_lens": []
}},
"lab_labels": [],
"detected_signals": {{}},
"sources_consulted": [],
"notes": []
}}
"""
return {
"health_analysis": json_data
}
2. CSS Position Check (Important)
If the button is now in the DOM but "scrolling away" or hidden behind the header, update src/components/AudioButton.css.
If you want the button to float on the screen (stay in the corner even when you scroll), change position: absolute to position: fixed.
/* src/components/AudioButton.css */
.audio-fab {
/* ⚠️ "fixed" makes it stick to the phone screen, not the scrollable list */
position: fixed;
top: 110px; /* Push it down below the "Real Food Score" title area if needed */
right: 20px;
z-index: 999; /* Force it on top of everything */
/* ... rest of your styles ... */
width: 50px;
height: 50px;
border-radius: 50%;
background: white;
border: none;
box-shadow: 0 4px 15px rgba(0,0,0,0.15);
display: flex;
justify-content: center;
align-items: center;
font-size: 1.5rem;
}
Why fixed?
Since your .results-container is inside a scrolling div (.results-list), using absolute puts the button at the top of the content. If you scroll down to read ingredients, the button scrolls up and disappears. fixed keeps it pinned to the glass of your phone screen.
In the main, I return audio_base64 as separate not in the health_analysis. So can i create separate type in the types file?
Yes! That is actually the much cleaner and professional way to handle it. Separation of concerns is good: the "Health Analysis" is one thing, the "Audio Data" is another.
Here is how to refactor your code to handle them separately.
Step 1: Clean up src/types.ts
We will remove audio_base64 from the RealFoodAnalysis (since it's not in that JSON) and create a new type for the API Response.
// src/types.ts
// 1. The inner detailed analysis (The JSON string)
export interface RealFoodAnalysis {
lens: string
score: number
bar: {
positive_ratio: number
negative_ratio: number
}
reasons: {
positives: string[]
concerns: string[]
}
ingredients_breakdown: {
positive_for_lens: string[]
negative_for_lens: string[]
mixed_for_lens: string[]
neutral_for_lens: string[]
}
lab_labels: {
ingredient: string
plain_english: string
why_added: string
}[]
// ❌ REMOVED audio_base64 from here. It belongs to the parent response!
}
// 2. The Raw Response from your Backend
export interface APIResponse {
health_analysis: string; // This is the stringified JSON we need to parse
audio_base64?: string; // This sits alongside it
narrative_text?: string;
}
Step 2: Update CameraCapture.tsx (Handle Separate States)
We need a new state variable for the audio, and we pass it separately to the results component.
// src/components/CameraCapture.tsx
import { useState, type ChangeEvent } from 'react'
import { type RealFoodAnalysis, type APIResponse } from '../types' // Import new types
// ... imports
export default function CameraCapture() {
const [image, setImage] = useState<string | null>(null)
// 1. Separate States
const [analysisData, setAnalysisData] = useState<RealFoodAnalysis | null>(null)
const [audioData, setAudioData] = useState<string | undefined>(undefined)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string>("")
// ... lens state ...
const handleImageChange = async (e: ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files[0]) {
// ... image setup ...
// Reset states
setAnalysisData(null)
setAudioData(undefined) // Reset audio
setLoading(true)
try {
const response = await fetch("http://10.189.4.149:8000/api/analyzehardcoded", {
method: "POST",
body: formData,
})
// 2. Use the APIResponse type
const data: APIResponse = await response.json()
// A. Handle Audio (Save directly to state)
if (data.audio_base64) {
setAudioData(data.audio_base64)
}
// B. Handle Analysis (Parse the string)
let parsedAnalysis;
try {
parsedAnalysis = typeof data.health_analysis === 'string'
? JSON.parse(data.health_analysis)
: data.health_analysis;
setAnalysisData(parsedAnalysis)
} catch (e) {
setError("Could not read ingredients format.")
}
} catch (err) {
setError("Error: Could not reach the brain! 🧠❌")
} finally {
setLoading(false)
}
}
}
// ... rest of render ...
{/* RIGHT SIDE: Pass them as TWO separate props */}
<div className="results-list">
<AnalysisResults
data={analysisData}
audio={audioData} // <--- Pass audio separately
loading={loading}
error={error}
/>
</div>
// ...
}
Step 3: Update AnalysisResults.tsx (Accept Separate Props)
Now we update the component to accept audio as its own prop. This is much cleaner than destructuring it from data.
// src/components/AnalysisResults.tsx
import './AnalysisResult.css'
import type { RealFoodAnalysis } from '../types'
import AudioButton from './AudioButton'
interface Props {
data: RealFoodAnalysis | null;
audio?: string; // <--- New separate prop
loading: boolean;
error: string;
}
export default function AnalysisResults({ data, audio, loading, error }: Props) {
// ... helper functions ...
if (loading) return ( ... );
if (error) return ( ... );
if (!data) return null;
// Destructure ONLY the health data (Audio is already in 'audio' variable)
const {
lens,
score,
bar,
reasons,
ingredients_breakdown,
lab_labels
} = data;
return (
<div className="results-container">
{/* 🔊 USE THE SEPARATE PROP */}
<AudioButton base64Audio={audio} />
{/* 1. BIG SCORE CARD */}
<div className="score-card">
{/* ... rest of code stays exactly the same ... */}
try {
// ⚠️ Make sure this IP matches your computer!
const response = await fetch("http://10.189.4.149:8000/api/analyze", {
method: "POST",
body: formData,
})
const data: APIResponse = await response.json()
let parsedAnalysis = JSON.parse(data.health_analysis);
if (data.audio_base64) {
parsedAnalysis.audio_base64 = data.audio_base64;
}
setRealFoodAnalysis(parsedAnalysis);
// Parse the JSON string from Backboard
let parsedData;
try {
parsedData = typeof data.health_analysis === 'string'
? JSON.parse(data.health_analysis)
: data.health_analysis;
} catch (e) {
parsedData = data.health_analysis;
}
if (parsedData) {
setRealFoodAnalysis(parsedData)
} else {
setError("Could not read ingredients format.")
}
} catch (err) {
setError("Error: Could not reach the brain! 🧠❌")
} finally {
setLoading(false)
}
}
}
const hasResults = realFoodAnalysis !== null || loading; // Keep results screen open if loading
You have a bit of redundant code there where you parse the JSON twice. This can cause bugs or just make the code harder to read.
Here is the cleaned-up, correct version of that try block. It handles the parsing once, injects the audio securely, and sets the state.
Corrected Logic for CameraCapture.tsx
try {
// 1. Fetch from Backend
const response = await fetch("http://10.189.4.149:8000/api/analyze", {
method: "POST",
body: formData,
})
if (!response.ok) throw new Error("Backend failed");
const data = await response.json(); // Don't enforce type yet to allow flexibility
// 2. Parse the 'health_analysis' string into an object
let parsedAnalysis;
try {
parsedAnalysis = typeof data.health_analysis === 'string'
? JSON.parse(data.health_analysis)
: data.health_analysis;
} catch (e) {
console.error("JSON Parse Error:", e);
throw new Error("Could not read ingredients format.");
}
// 3. Inject the Audio Base64 (if it exists)
if (data.audio_base64) {
parsedAnalysis.audio_base64 = data.audio_base64;
}
// 4. Update State
if (parsedAnalysis) {
setRealFoodAnalysis(parsedAnalysis);
}
} catch (err) {
console.error(err);
setError("Error: Could not reach the brain! 🧠❌");
} finally {
setLoading(false);
}
Key Changes:
Removed Duplicate Parsing: You were calling
JSON.parseat the top, and then again inside atry/catchblock below it. I consolidated it into one safe block.Audio Injection: The
audio_base64is now added toparsedAnalysisbefore we save it to state. This ensures the audio button gets the data immediately.Safety Check: Added
if (!response.ok)to catch 404 or 500 errors from the server properly.
Update src/types.ts (One last check)
Ensure your RealFoodAnalysis type (which parsedAnalysis becomes) has the optional audio field so TypeScript doesn't complain about line 3:
export interface RealFoodAnalysis {
lens: string;
score: number;
audio_base64?: string; // <--- Make sure this is here!
// ... rest of fields
}
try {
// 1. Fetch from Backend
const response = await fetch("http://10.189.4.149:8000/api/analyze", {
method: "POST",
body: formData,
})
if (!response.ok) throw new Error("Backend failed");
const data = await response.json(); // Don't enforce type yet to allow flexibility
// 2. Parse the 'health_analysis' string into an object
let parsedAnalysis;
try {
parsedAnalysis = typeof data.health_analysis === 'string'
? JSON.parse(data.health_analysis)
: data.health_analysis;
} catch (e) {
console.error("JSON Parse Error:", e);
throw new Error("Could not read ingredients format.");
}
// 3. Inject the Audio Base64 (if it exists)
if (data.audio_base64) {
parsedAnalysis.audio_base64 = data.audio_base64;
}
// 4. Update State
if (parsedAnalysis) {
setRealFoodAnalysis(parsedAnalysis);
}
} catch (err) {
console.error(err);
setError("Error: Could not reach the brain! 🧠❌");
} finally {
setLoading(false);
}
Can you use the APIResponse and setAudioData for this?
Yes! This is the cleanest way to handle it. We treat the Audio and the Health Analysis as two separate pieces of data from the API.
Here is your updated try/catch block using the APIResponse type and the separate setAudioData setter.
Update src/components/CameraCapture.tsx
Make sure you have defined the state at the top of your component first:
const [audioData, setAudioData] = useState<string | null>(null);
Then replace your handleImageChange logic with this:
try {
// 1. Fetch from Backend
const response = await fetch("http://10.189.4.149:8000/api/analyze", {
method: "POST",
body: formData,
})
if (!response.ok) throw new Error("Backend failed");
// 2. Use the APIResponse Type
// This tells TypeScript that 'data' has 'health_analysis' and 'audio_base64'
const data: APIResponse = await response.json();
// --- A. HANDLE AUDIO (Separate State) ---
if (data.audio_base64) {
setAudioData(data.audio_base64);
}
// --- B. HANDLE HEALTH ANALYSIS (Separate State) ---
let parsedAnalysis;
try {
// It comes as a string, so we must parse it
parsedAnalysis = typeof data.health_analysis === 'string'
? JSON.parse(data.health_analysis)
: data.health_analysis;
} catch (e) {
console.error("JSON Parse Error:", e);
throw new Error("Could not read ingredients format.");
}
if (parsedAnalysis) {
setRealFoodAnalysis(parsedAnalysis);
}
} catch (err) {
console.error(err);
setError("Error: Could not reach the brain! 🧠❌");
} finally {
setLoading(false);
}
Why this is better:
Type Safety:
const data: APIResponseensures you don't accidentally try to access fields that don't exist on the raw response.No Injection: You aren't "hacking" the
parsedAnalysisobject by shoving audio into it. The Health data stays pure, and the Audio data stays separate.React Best Practice: Using separate state variables (
realFoodAnalysisvsaudioData) allows React to update them independently if needed (e.g., if you wanted to regenerate only the audio later without re-analyzing the image).
{{
"lens": "personal Fit Score",
"score": <int 0-100>,
"bar": {{
"fits_you_points": <number>,
"conflicts_points": <number>,
"fits_you_ratio": <number 0-1>,
"conflicts_ratio": <number 0-1>
}},
"reasons": {{
"positives": [ "<✅ ...>", "<✅ ...>", "<✅ ...>" ],
"concerns": [ "<⚠️ ...>", "<⚠️ ...>", "<⚠️ ...>" ]
}},
"criteria_hits": [
{{ "id": "H1", "name": "Allergen present", "direction": "negative", "points": 70, "evidence": "contains milk (dairy allergen)" }}
],
"ingredients_breakdown": {{
"positive_for_lens": [ "<ingredient>", ... ],
"negative_for_lens": [ "<ingredient>", ... ],
"mixed_for_lens": [ "<ingredient>", ... ],
"neutral_for_lens": [ "<ingredient>", ... ]
}},
"lab_labels": [
{{
"ingredient": "<string>",
"plain_english": "<string>",
"why_added": "<string>",
"personal_relevance": "<string>"
}}
],
"detected_signals": {{
"first_ingredient": "<string>",
"ingredient_count": <int>,
"goal": "<string>",
"dietary_rules": [..],
"avoid_list": [..],
"allergens": [..],
"health_considerations": [..],
"sugars": {{ "terms": [..], "positions": [..] }},
"sweeteners": [..],
"colors": [..],
"stimulants": [..],
"omega3_sources": [..],
"nuts_seeds": [..],
"whole_grains": [..],
"protein_cues": [..],
"creatine": <true/false>,
"additive_stack_markers": [..],
"hard_fail_penalty": <number>
}},
"sources_consulted": [
{{ "source": "Harvard Health Publishing (Harvard Medical School)", "how_used": "Broad nutrition/brain-health framing; supports conservative wording." }},
{{ "source": "Mayo Clinic", "how_used": "General nutrition/wellness framing; consumer-friendly explanations." }},
{{ "source": "WebMD", "how_used": "Plain-language reference; avoid strong causal claims." }},
{{ "source": "Cleveland Clinic", "how_used": "Broad clinical health framing; cautious explanations for sugars/stimulants/additives." }}
],
"notes": [ "<uncertainty/caveats>" ]
}}
Now for this lense, it's different json output so how should I AnalyseResult?
Since the JSON structure is slightly different (e.g., fits_you_ratio instead of positive_ratio), we need to make your Frontend flexible enough to handle both formats.
Here is the 2-step fix: Update the Types to allow these new fields, and update the Component to map them correctly.
Step 1: Update src/types.ts
We need to make the bar and lab_labels definitions flexible so they can accept either the "Real Food" format OR the "Personal" format.
// src/types.ts
export interface APIResponse {
health_analysis: string;
audio_base64?: string;
narrative_text?: string;
}
export interface AnalysisData {
lens: string;
score: number;
// Flexible Bar (Can be Real Food OR Personal)
bar: {
// Option A: Real Food / Focus
positive_ratio?: number;
negative_ratio?: number;
positive_points?: number;
negative_points?: number;
// Option B: Personal Lens
fits_you_ratio?: number;
conflicts_ratio?: number;
fits_you_points?: number;
conflicts_points?: number;
};
reasons: {
positives: string[];
concerns: string[];
};
ingredients_breakdown: {
positive_for_lens: string[];
negative_for_lens: string[];
mixed_for_lens: string[];
neutral_for_lens: string[];
};
lab_labels: {
ingredient: string;
plain_english: string;
why_added: string;
// Optional specifics
common_in?: string[];
focus_relevance?: string;
personal_relevance?: string;
}[];
// Allow other loose fields so strict typing doesn't break
detected_signals?: any;
sources_consulted?: any[];
notes?: string[];
}
Step 2: Update AnalysisResults.tsx
We will add a small logic block at the top of the component to normalize the data. This means "If I see fits_you_ratio, treat it as the Green Bar."
// src/components/AnalysisResult.tsx
import './AnalysisResult.css'
import type { AnalysisData } from '../types' // Updated import name
import AudioButton from './AudioButton'
interface Props {
data: AnalysisData | null; // Use the new flexible type
audio?: string;
loading: boolean;
error: string;
}
export default function AnalysisResults({ data, audio, loading, error }: Props) {
// Helper for Score Color
const getScoreColor = (score: number) => {
if (score >= 80) return '#2ecc71'; // Green
if (score >= 50) return '#f1c40f'; // Yellow
return '#e74c3c'; // Red
}
if (loading) return (
<div className="results-container loading">
<div className="spinner">🧠</div>
<p>Analyzing...</p>
</div>
)
if (error) return <div className="results-container error"><p>⚠️ {error}</p></div>
if (!data) return null;
const { lens, score, bar, reasons, ingredients_breakdown, lab_labels } = data;
// --- 🧠 LOGIC: Normalize the Data ---
// Depending on the lens, the JSON keys might be different.
// We map them to standard variables for our UI.
const isPersonal = lens === "personal Fit Score";
// 1. Bar Ratios
const greenRatio = isPersonal ? (bar.fits_you_ratio || 0.1) : (bar.positive_ratio || 0.1);
const redRatio = isPersonal ? (bar.conflicts_ratio || 0.1) : (bar.negative_ratio || 0.1);
// 2. Bar Labels
const greenLabel = isPersonal ? "Fits You" : "Positive";
const redLabel = isPersonal ? "Conflicts" : "Concerns";
// 3. Dropdown Titles
const greenDropTitle = isPersonal ? "✅ Fits Your Goals" : "🥗 Whole Foods / Helpful";
const redDropTitle = isPersonal ? "❌ Conflicts / Allergens" : "⚠️ Concerning";
return (
<div className="results-container">
{/* 🔊 AUDIO BUTTON */}
<AudioButton base64Audio={audio} />
{/* 1. BIG SCORE CARD */}
<div className="score-card">
<h3 className="card-title">{lens?.replace(/_/g, ' ') || "Analysis"}</h3>
<div className="score-circle" style={{ borderColor: getScoreColor(score) }}>
<span className="score-number" style={{ color: getScoreColor(score) }}>
{score}
</span>
<span className="score-label">/ 100</span>
</div>
</div>
{/* 2. THE VISUAL BAR (Dynamic Labels) */}
<div className="bar-section">
<div className="bar-labels">
<span style={{color: '#2ecc71'}}>{greenLabel}</span>
<span style={{color: '#e74c3c'}}>{redLabel}</span>
</div>
<div className="progress-bar-container">
{/* Green Bar */}
<div
className="bar-segment positive"
style={{ flex: greenRatio }}
/>
{/* Spacer (Neutral) */}
<div
className="bar-segment neutral"
style={{ flex: 0.05, background: '#eee' }}
/>
{/* Red Bar */}
<div
className="bar-segment negative"
style={{ flex: redRatio }}
/>
</div>
</div>
{/* 3. TOP REASONS */}
<div className="reasons-section">
{reasons.positives.length > 0 && (
<div className="reason-box good">
<h4>✅ {isPersonal ? "Why it fits you" : "Why it's good"}</h4>
<ul>
{reasons.positives.map((r, i) => <li key={i}>{r}</li>)}
</ul>
</div>
)}
{reasons.concerns.length > 0 && (
<div className="reason-box bad">
<h4>⚠️ {isPersonal ? "Conflict Warning" : "Concerns"}</h4>
<ul>
{reasons.concerns.map((r, i) => <li key={i}>{r}</li>)}
</ul>
</div>
)}
</div>
{/* 4. INGREDIENT BREAKDOWN */}
<h3 className="section-header">Ingredient Breakdown</h3>
<IngredientDropdown
title={greenDropTitle}
items={ingredients_breakdown.positive_for_lens}
color="green"
/>
<IngredientDropdown
title="🤷 Mixed / Complex"
items={ingredients_breakdown.mixed_for_lens}
color="orange"
/>
<IngredientDropdown
title="🧂 Neutral"
items={ingredients_breakdown.neutral_for_lens}
color="grey"
/>
<IngredientDropdown
title={redDropTitle}
items={ingredients_breakdown.negative_for_lens}
color="red"
isOpen={true}
/>
{/* 5. LAB LABELS */}
{lab_labels && lab_labels.length > 0 && (
<div className="lab-labels-section">
<h3 className="section-header">📚 Lab Labels</h3>
<div className="lab-labels-list">
{lab_labels.map((label, i) => (
<div key={i} className="lab-card">
<div className="lab-name">{label.ingredient}</div>
<div className="lab-desc">{label.plain_english}</div>
{/* Show extra context if available */}
{label.personal_relevance && (
<div className="lab-meta warning">
<strong>Relevance:</strong> {label.personal_relevance}
</div>
)}
{label.focus_relevance && (
<div className="lab-meta">
<strong>Focus:</strong> {label.focus_relevance}
</div>
)}
<div className="lab-meta">
<strong>Function:</strong> {label.why_added}
</div>
</div>
))}
</div>
</div>
)}
</div>
)
}
// Mini Component for the Dropdowns
function IngredientDropdown({ title, items, color, isOpen = false }: any) {
if (!items || items.length === 0) return null;
return (
<details className={`ing-dropdown ${color}`} open={isOpen}>
<summary>
{title}
<span className="count-badge">{items.length}</span>
</summary>
<div className="dropdown-content">
{items.map((item: string, i: number) => (
<div key={i} className="ing-chip">{item}</div>
))}
</div>
</details>
)
}
actually can you treat this as a different components and types and everything?
This is a much better approach! Separating them keeps your code clean and prevents "spaghetti code" where one component tries to do too many different things.
We will create a dedicated PersonalFitResult.tsx component and a specific type for it.
Step 1: Update src/types.ts
Add this specific type for the Personal Lens JSON structure.
// src/types.ts
// ... existing types ...
// 1. NEW: Specific Type for Personal Lens
export interface PersonalFitAnalysis {
lens: string;
score: number;
bar: {
fits_you_points: number;
conflicts_points: number;
fits_you_ratio: number;
conflicts_ratio: number;
};
reasons: {
positives: string[];
concerns: string[];
};
criteria_hits: {
id: string;
name: string;
direction: string;
points: number;
evidence: string;
}[];
ingredients_breakdown: {
positive_for_lens: string[];
negative_for_lens: string[];
mixed_for_lens: string[];
neutral_for_lens: string[];
};
lab_labels: {
ingredient: string;
plain_english: string;
why_added: string;
personal_relevance: string;
}[];
// Allow flexible extras
detected_signals?: any;
sources_consulted?: any[];
notes?: string[];
}
Step 2: Create src/components/PersonalFitResult.tsx
This component is built specifically for the Personal Lens data structure.
// src/components/PersonalFitResult.tsx
import './AnalysisResult.css' // We can reuse the same CSS!
import type { PersonalFitAnalysis } from '../types'
import AudioButton from './AudioButton'
interface Props {
data: PersonalFitAnalysis | null;
audio?: string;
loading: boolean;
error: string;
}
export default function PersonalFitResult({ data, audio, loading, error }: Props) {
// Helper for Score Color (Personal Fit might use different colors?)
const getScoreColor = (score: number) => {
if (score >= 80) return '#2ecc71'; // Green (Great Fit)
if (score >= 50) return '#f1c40f'; // Yellow (Okay)
return '#e74c3c'; // Red (Bad Fit)
}
if (loading) return (
<div className="results-container loading">
<div className="spinner">👤</div>
<p>Checking your personal fit...</p>
</div>
)
if (error) return <div className="results-container error"><p>⚠️ {error}</p></div>
if (!data) return null;
// Destructure the PERSONAL FIT specific fields
const { lens, score, bar, reasons, ingredients_breakdown, lab_labels } = data;
return (
<div className="results-container">
<AudioButton base64Audio={audio} />
{/* 1. PERSONAL SCORE CARD */}
<div className="score-card">
<h3 className="card-title">Personal Match</h3>
<div className="score-circle" style={{ borderColor: getScoreColor(score) }}>
<span className="score-number" style={{ color: getScoreColor(score) }}>
{score}%
</span>
<span className="score-label">Match</span>
</div>
</div>
{/* 2. FITS YOU vs CONFLICTS Bar */}
<div className="bar-section">
<div className="bar-labels">
<span style={{color: '#2ecc71'}}>Fits You</span>
<span style={{color: '#e74c3c'}}>Conflicts</span>
</div>
<div className="progress-bar-container">
{/* Fits You Bar */}
<div
className="bar-segment positive"
style={{ flex: bar.fits_you_ratio || 0.1 }}
/>
{/* Conflicts Bar */}
<div
className="bar-segment negative"
style={{ flex: bar.conflicts_ratio || 0.1 }}
/>
</div>
</div>
{/* 3. REASONS (Specific Titles) */}
<div className="reasons-section">
{reasons.positives.length > 0 && (
<div className="reason-box good">
<h4>✅ Matches Your Goals</h4>
<ul>
{reasons.positives.map((r, i) => <li key={i}>{r}</li>)}
</ul>
</div>
)}
{reasons.concerns.length > 0 && (
<div className="reason-box bad">
<h4>❌ Conflicts Found</h4>
<ul>
{reasons.concerns.map((r, i) => <li key={i}>{r}</li>)}
</ul>
</div>
)}
</div>
{/* 4. INGREDIENT BREAKDOWN */}
<h3 className="section-header">Ingredient Analysis</h3>
{/* Red first for Personal Fit (Show allergens/conflicts immediately) */}
<IngredientDropdown
title="⛔ Conflicts / Allergens"
items={ingredients_breakdown.negative_for_lens}
color="red"
isOpen={true}
/>
<IngredientDropdown
title="✅ Safe / Good for You"
items={ingredients_breakdown.positive_for_lens}
color="green"
/>
<IngredientDropdown
title="🤷 Mixed"
items={ingredients_breakdown.mixed_for_lens}
color="orange"
/>
<IngredientDropdown
title="🧂 Neutral"
items={ingredients_breakdown.neutral_for_lens}
color="grey"
/>
{/* 5. LAB LABELS (Personal Relevance) */}
{lab_labels && lab_labels.length > 0 && (
<div className="lab-labels-section">
<h3 className="section-header">📚 Personal Relevance</h3>
<div className="lab-labels-list">
{lab_labels.map((label, i) => (
<div key={i} className="lab-card">
<div className="lab-name">{label.ingredient}</div>
<div className="lab-desc">{label.plain_english}</div>
{/* ⚠️ This field is unique to Personal Fit */}
<div className="lab-meta warning">
<strong>Why it matters to you:</strong> {label.personal_relevance}
</div>
</div>
))}
</div>
</div>
)}
</div>
)
}
// Reusing the same dropdown helper
function IngredientDropdown({ title, items, color, isOpen = false }: any) {
if (!items || items.length === 0) return null;
return (
<details className={`ing-dropdown ${color}`} open={isOpen}>
<summary>
{title}
<span className="count-badge">{items.length}</span>
</summary>
<div className="dropdown-content">
{items.map((item: string, i: number) => (
<div key={i} className="ing-chip">{item}</div>
))}
</div>
</details>
)
}
Step 3: Update CameraCapture.tsx (Switch Components)
Now, in your main file, you check which lens is selected and render the correct component.
// src/components/CameraCapture.tsx
import PersonalFitResult from './PersonalFitResult'; // Import new component
// ... other imports
export default function CameraCapture() {
// ... state ...
return (
<div className="camera-container">
{/* ... slider wrapper ... */}
<div className="results-list">
{/* ⚡️ CONDITIONAL RENDERING */}
{selectedLens === 'personal' ? (
<PersonalFitResult
data={analysisData as any} // Cast because types might slightly overlap
audio={audioData}
loading={loading}
error={error}
/>
) : (
<AnalysisResults
data={analysisData as any}
audio={audioData}
loading={loading}
error={error}
/>
)}
</div>
</div>
)
}
I want to add User Information in the UserProfile page for the background
// src/components/CameraCapture.tsx
import { useState, type ChangeEvent } from 'react'
import './CameraCapture.css'
import { type RealFoodAnalysis, type APIResponse, type PersonalFitAnalysis} from '../types' // Import new component
import AnalysisResults from './AnalysisResult' // Import the new results component
import PersonalFitResult from './PersonalFitResult';
// Define the available lenses
type LensType = 'focus' | 'real_food' | 'personal';
export default function CameraCapture() {
const [image, setImage] = useState<string | null>(null)
const [realFoodAnalysis, setRealFoodAnalysis] = useState<RealFoodAnalysis | null>(null)
const [personalFitAnalysis, setPersonalFitAnalysis] = useState<PersonalFitAnalysis | null>(null)
const [audioData, setAudioData] = useState<string | undefined>(undefined)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string>("")
const [selectedLens, setSelectedLens] = useState<LensType>('real_food') // Default to 'real_food' lens
const handleImageChange = async (e: ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files[0]) {
const file = e.target.files[0]
const url = URL.createObjectURL(file)
setImage(url)
// Reset states
setRealFoodAnalysis(null)
setError("")
setLoading(true)
const formData = new FormData()
formData.append("file", file)
formData.append("lens", selectedLens)
try {
// 1. Fetch from Backend
const response = await fetch("http://172.20.10.3:8000/api/analyze", {
method: "POST",
body: formData,
})
if (!response.ok) throw new Error("Backend failed");
// 2. Use the APIResponse Type
// This tells TypeScript that 'data' has 'health_analysis' and 'audio_base64'
const data: APIResponse = await response.json();
// --- A. HANDLE AUDIO (Separate State) ---
if (data.audio_base64) {
setAudioData(data.audio_base64);
}
// --- B. HANDLE HEALTH ANALYSIS (Separate State) ---
let parsedAnalysis;
try {
// It comes as a string, so we must parse it
parsedAnalysis = typeof data.health_analysis === 'string'
? JSON.parse(data.health_analysis)
: data.health_analysis;
} catch (e) {
console.error("JSON Parse Error:", e);
throw new Error("Could not read ingredients format.");
}
if (parsedAnalysis) {
if (selectedLens === 'personal') {
setPersonalFitAnalysis(parsedAnalysis as PersonalFitAnalysis);
} else {
setRealFoodAnalysis(parsedAnalysis as RealFoodAnalysis);
}
}
} catch (err) {
console.error(err);
setError("Error: Could not reach the brain! 🧠❌");
} finally {
setLoading(false);
}
}
}
const hasResults = (realFoodAnalysis !== null || personalFitAnalysis !== null) || loading;
return (
<div className="camera-container">
{/* THE SLIDING WRAPPER */}
<div className={`sliding-wrapper ${hasResults ? 'show-results' : 'show-camera'}`}>
{/* LEFT SIDE: Camera */}
<div className="image-area">
{/* 3. NEW: LENS SELECTOR OVERLAY */}
{/* Only show if we haven't taken a photo yet, or just always show it?
Usually nice to hide while loading, but keeping it visible is fine too. */}
{!image && (
<div className="lens-overlay">
<button
className={`lens-btn ${selectedLens === 'focus' ? 'active' : ''}`}
onClick={() => setSelectedLens('focus')}
>
<span className="lens-icon">🔍</span>
<span className="lens-label">Focus</span>
</button>
<button
className={`lens-btn ${selectedLens === 'real_food' ? 'active' : ''}`}
onClick={() => setSelectedLens('real_food')}
>
<span className="lens-icon">🥗</span>
<span className="lens-label">Real Food</span>
</button>
<button
className={`lens-btn ${selectedLens === 'personal' ? 'active' : ''}`}
onClick={() => setSelectedLens('personal')}
>
<span className="lens-icon">👤</span>
<span className="lens-label">Personal</span>
</button>
</div>
)}
{image ? (
<img src={image} alt="Captured" className="photo-preview" />
) : (
<div className="placeholder">
<div style={{fontSize: '3rem', marginBottom: '10px'}}>📸</div>
<p>Take a photo</p>
</div>
)}
</div>
{/* RIGHT SIDE: The New Results Component */}
<div className="results-list">
{selectedLens === 'personal' ? (
<PersonalFitResult
data={personalFitAnalysis as any} // Cast because types might slightly overlap
audio={audioData}
loading={loading}
error={error}
/>
) : (
<AnalysisResults
data={realFoodAnalysis as any}
audio={audioData}
loading={loading}
error={error}
/>
)}
</div>
</div>
{/* BUTTON (Always visible) */}
<div className="controls-area">
<input
type="file"
accept="image/*"
capture="environment"
onChange={handleImageChange}
id="cameraInput"
style={{ display: 'none' }}
/>
<label htmlFor="cameraInput" className="camera-button">
{loading ? "Scanning..." : (realFoodAnalysis ? "📸 Scan New" : "📸 Take Photo")}
</label>
</div>
</div>
)
}
To add User Information (like diet, allergies, and goals) that acts as the "background" context for the Personal Lens, we need to do three things:
Update Types: Define what a User Profile looks like.
Create the Profile Page: A component to enter this information.
Update CameraCapture: To store this profile and send it to the backend when analyzing.
Here is the complete implementation.
Step 1: Update src/types.ts
Add the UserProfile definition so we can use it everywhere.
// src/types.ts
// ... existing types ...
// 1. NEW: User Profile Definition
export interface UserProfile {
name: string;
diet: 'Normal' | 'Vegan' | 'Vegetarian' | 'Keto' | 'Paleo';
allergies: string[]; // e.g. ["Peanuts", "Dairy"]
goals: string[]; // e.g. ["Energy", "Gut Health"]
}
// ... existing PersonalFitAnalysis ...
Step 2: Create src/components/UserProfile.tsx
This is the new page where users input their data. It saves to the parent state when they click "Save".
// src/components/UserProfile.tsx
import { useState } from 'react';
import './UserProfile.css';
import { UserProfile } from '../types';
interface Props {
currentProfile: UserProfile;
onSave: (profile: UserProfile) => void;
onClose: () => void;
}
export default function UserProfilePage({ currentProfile, onSave, onClose }: Props) {
const [formData, setFormData] = useState<UserProfile>(currentProfile);
// Toggle helper for arrays (allergies/goals)
const toggleItem = (field: 'allergies' | 'goals', value: string) => {
setFormData(prev => {
const list = prev[field];
if (list.includes(value)) {
return { ...prev, [field]: list.filter(item => item !== value) };
} else {
return { ...prev, [field]: [...list, value] };
}
});
};
return (
<div className="profile-overlay">
<div className="profile-card">
<h2>👤 Your Personal Context</h2>
<p className="subtitle">This info trains the "Personal" lens.</p>
{/* 1. NAME */}
<div className="form-group">
<label>Name</label>
<input
type="text"
value={formData.name}
onChange={e => setFormData({...formData, name: e.target.value})}
placeholder="Enter your name"
/>
</div>
{/* 2. DIET */}
<div className="form-group">
<label>Dietary Style</label>
<div className="chip-grid">
{['Normal', 'Vegan', 'Vegetarian', 'Keto', 'Paleo'].map(diet => (
<button
key={diet}
className={`chip ${formData.diet === diet ? 'active' : ''}`}
onClick={() => setFormData({...formData, diet: diet as any})}
>
{diet}
</button>
))}
</div>
</div>
{/* 3. ALLERGIES */}
<div className="form-group">
<label>Allergies / Avoid</label>
<div className="chip-grid">
{['Gluten', 'Dairy', 'Peanuts', 'Soy', 'Shellfish'].map(item => (
<button
key={item}
className={`chip red ${formData.allergies.includes(item) ? 'active' : ''}`}
onClick={() => toggleItem('allergies', item)}
>
{item}
</button>
))}
</div>
</div>
{/* 4. GOALS */}
<div className="form-group">
<label>Health Goals</label>
<div className="chip-grid">
{['Energy', 'Muscle', 'Gut Health', 'Focus', 'Weight Loss'].map(item => (
<button
key={item}
className={`chip green ${formData.goals.includes(item) ? 'active' : ''}`}
onClick={() => toggleItem('goals', item)}
>
{item}
</button>
))}
</div>
</div>
{/* ACTIONS */}
<div className="profile-actions">
<button className="cancel-btn" onClick={onClose}>Cancel</button>
<button className="save-btn" onClick={() => onSave(formData)}>✅ Save Profile</button>
</div>
</div>
</div>
);
}
Add Styles (src/components/UserProfile.css):
.profile-overlay {
position: absolute; top: 0; left: 0; width: 100%; height: 100%;
background: rgba(0,0,0,0.8); z-index: 200;
display: flex; justify-content: center; align-items: flex-end; /* Slide up from bottom */
}
.profile-card {
background: white; width: 100%; height: 90%;
border-radius: 20px 20px 0 0; padding: 25px;
overflow-y: auto; animation: slideUpProfile 0.3s ease;
}
h2 { margin: 0; color: #333; }
.subtitle { color: #666; font-size: 0.9rem; margin-bottom: 20px; }
.form-group { margin-bottom: 20px; }
label { display: block; font-weight: 700; margin-bottom: 8px; color: #444; }
input { width: 100%; padding: 12px; border: 1px solid #ddd; border-radius: 8px; font-size: 1rem; }
.chip-grid { display: flex; flex-wrap: wrap; gap: 8px; }
.chip { padding: 8px 16px; border-radius: 20px; border: 1px solid #eee; background: #f9f9f9; color: #555; font-weight: 600; cursor: pointer; transition: 0.2s; }
.chip.active { background: #333; color: white; border-color: #333; }
.chip.red.active { background: #e74c3c; border-color: #e74c3c; }
.chip.green.active { background: #2ecc71; border-color: #2ecc71; }
.profile-actions { display: flex; gap: 10px; margin-top: 30px; }
.save-btn { flex: 2; background: #007bff; color: white; padding: 15px; border-radius: 12px; border: none; font-weight: 700; font-size: 1.1rem; }
.cancel-btn { flex: 1; background: transparent; border: 1px solid #ccc; border-radius: 12px; color: #666; }
@keyframes slideUpProfile { from { transform: translateY(100%); } to { transform: translateY(0); } }
Step 3: Update CameraCapture.tsx
Now we integrate everything. We add a Profile Button to the main screen, handle the state, and—most importantly—send the profile JSON to the backend when the lens is "personal".
// src/components/CameraCapture.tsx
import { useState, type ChangeEvent } from 'react'
import './CameraCapture.css'
import { type RealFoodAnalysis, type APIResponse, type PersonalFitAnalysis, type UserProfile } from '../types'
import AnalysisResults from './AnalysisResult'
import PersonalFitResult from './PersonalFitResult';
import UserProfilePage from './UserProfile'; // <--- Import the new page
type LensType = 'focus' | 'real_food' | 'personal';
export default function CameraCapture() {
const [image, setImage] = useState<string | null>(null)
// Data States
const [realFoodAnalysis, setRealFoodAnalysis] = useState<RealFoodAnalysis | null>(null)
const [personalFitAnalysis, setPersonalFitAnalysis] = useState<PersonalFitAnalysis | null>(null)
const [audioData, setAudioData] = useState<string | undefined>(undefined)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string>("")
const [selectedLens, setSelectedLens] = useState<LensType>('real_food')
// --- 1. NEW USER PROFILE STATE ---
const [showProfile, setShowProfile] = useState(false);
const [userProfile, setUserProfile] = useState<UserProfile>({
name: "User",
diet: "Normal",
allergies: [],
goals: []
});
const handleImageChange = async (e: ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files[0]) {
const file = e.target.files[0]
const url = URL.createObjectURL(file)
setImage(url)
setRealFoodAnalysis(null)
setPersonalFitAnalysis(null) // Reset personal state too
setError("")
setLoading(true)
const formData = new FormData()
formData.append("file", file)
formData.append("lens", selectedLens)
// --- 2. SEND PROFILE TO BACKEND ---
// If using Personal Lens, we attach the user profile as a JSON string
if (selectedLens === 'personal') {
formData.append("user_profile", JSON.stringify(userProfile));
}
try {
const response = await fetch("http://172.20.10.3:8000/api/analyze", {
method: "POST",
body: formData,
})
if (!response.ok) throw new Error("Backend failed");
const data: APIResponse = await response.json();
if (data.audio_base64) {
setAudioData(data.audio_base64);
}
let parsedAnalysis;
try {
parsedAnalysis = typeof data.health_analysis === 'string'
? JSON.parse(data.health_analysis)
: data.health_analysis;
} catch (e) {
console.error("JSON Parse Error:", e);
throw new Error("Could not read ingredients format.");
}
if (parsedAnalysis) {
if (selectedLens === 'personal') {
setPersonalFitAnalysis(parsedAnalysis as PersonalFitAnalysis);
} else {
setRealFoodAnalysis(parsedAnalysis as RealFoodAnalysis);
}
}
} catch (err) {
console.error(err);
setError("Error: Could not reach the brain! 🧠❌");
} finally {
setLoading(false);
}
}
}
const hasResults = (realFoodAnalysis !== null || personalFitAnalysis !== null) || loading;
return (
<div className="camera-container">
{/* --- 3. RENDER PROFILE PAGE (Overlay) --- */}
{showProfile && (
<UserProfilePage
currentProfile={userProfile}
onClose={() => setShowProfile(false)}
onSave={(updatedProfile) => {
setUserProfile(updatedProfile);
setShowProfile(false);
}}
/>
)}
{/* --- 4. PROFILE BUTTON (Top Left) --- */}
{!image && (
<button className="profile-toggle-btn" onClick={() => setShowProfile(true)}>
👤 {userProfile.name}
</button>
)}
<div className={`sliding-wrapper ${hasResults ? 'show-results' : 'show-camera'}`}>
{/* LEFT SIDE: Camera */}
<div className="image-area">
{/* LENS SELECTOR OVERLAY */}
{!image && (
<div className="lens-overlay">
<button
className={`lens-btn ${selectedLens === 'focus' ? 'active' : ''}`}
onClick={() => setSelectedLens('focus')}
>
<span className="lens-icon">🔍</span>
<span className="lens-label">Focus</span>
</button>
<button
className={`lens-btn ${selectedLens === 'real_food' ? 'active' : ''}`}
onClick={() => setSelectedLens('real_food')}
>
<span className="lens-icon">🥗</span>
<span className="lens-label">Real Food</span>
</button>
<button
className={`lens-btn ${selectedLens === 'personal' ? 'active' : ''}`}
onClick={() => setSelectedLens('personal')}
>
<span className="lens-icon">👤</span>
<span className="lens-label">Personal</span>
</button>
</div>
)}
{image ? (
<img src={image} alt="Captured" className="photo-preview" />
) : (
<div className="placeholder">
<div style={{fontSize: '3rem', marginBottom: '10px'}}>📸</div>
<p>Take a photo</p>
</div>
)}
</div>
{/* RIGHT SIDE: Results */}
<div className="results-list">
{selectedLens === 'personal' ? (
<PersonalFitResult
data={personalFitAnalysis as any}
audio={audioData}
loading={loading}
error={error}
/>
) : (
<AnalysisResults
data={realFoodAnalysis as any}
audio={audioData}
loading={loading}
error={error}
/>
)}
</div>
</div>
{/* ACTION BUTTON */}
<div className="controls-area">
<input
type="file"
accept="image/*"
capture="environment"
onChange={handleImageChange}
id="cameraInput"
style={{ display: 'none' }}
/>
<label htmlFor="cameraInput" className="camera-button">
{loading ? "Scanning..." : (hasResults ? "📸 Scan New" : "📸 Take Photo")}
</label>
</div>
</div>
)
}
Add this CSS to CameraCapture.css for the new profile button:
.profile-toggle-btn {
position: absolute;
top: 40px;
left: 20px;
background: rgba(255,255,255,0.2);
backdrop-filter: blur(5px);
border: 1px solid rgba(255,255,255,0.3);
color: white;
padding: 8px 16px;
border-radius: 20px;
font-weight: 600;
z-index: 50;
cursor: pointer;
display: flex;
align-items: center;
gap: 5px;
}
// src/components/UserProfile.tsx
import { useState } from 'react'
import './UserProfile.css' // <--- Import the new CSS file
export interface UserData {
goal: 'gain' | 'loss'
diet: 'Halal' | 'Vegetarian' | 'Vegan'
// Allergies
peanut: boolean
tree_nut: boolean
dairy: boolean
gluten: boolean
egg: boolean
shellfish: boolean
sesame: boolean
soy: boolean
// Avoid / sensitivities
avoid_artificial_colors: boolean
avoid_artificial_sweeteners: boolean
avoid_ultra_processed: boolean
caffeine_sensitive: boolean
// Flags
flags: string[]
}
export default function UserProfile() {
const [user, setUser] = useState<UserData>({
goal: 'gain',
diet: 'Halal',
peanut: false,
tree_nut: false,
dairy: false,
gluten: false,
egg: false,
shellfish: false,
sesame: false,
soy: false,
avoid_artificial_colors: false,
avoid_artificial_sweeteners: false,
avoid_ultra_processed: false,
caffeine_sensitive: false,
flags: [],
})
const [flagInput, setFlagInput] = useState('')
const handleChange = (field: keyof UserData, value: any) => {
setUser(prev => ({ ...prev, [field]: value }))
}
const handleAddFlag = () => {
if (flagInput.trim()) {
setUser(prev => ({ ...prev, flags: [...prev.flags, flagInput.trim()] }))
setFlagInput('')
}
}
const handleDeleteFlag = (index: number) => {
setUser(prev => ({ ...prev, flags: prev.flags.filter((_, i) => i !== index) }))
}
const handleUpdateFlag = (index: number, newValue: string) => {
setUser(prev => ({
...prev,
flags: prev.flags.map((flag, i) => (i === index ? newValue : flag))
}))
}
return (
<div className="profile-container">
<h1 className="profile-title">My Profile 👤</h1>
<div className="profile-card">
{/* Goal */}
<div className="form-group">
<label className="input-label">Goal</label>
<select
value={user.goal}
onChange={(e) => handleChange('goal', e.target.value as UserData['goal'])}
className="input-field"
style={{ backgroundImage: 'none' }}
>
<option value="gain">Gain</option>
<option value="loss">Loss</option>
</select>
</div>
{/* Diet Type */}
<div className="form-group">
<label className="input-label">Diet Type</label>
<select
value={user.diet}
onChange={(e) => handleChange('diet', e.target.value as UserData['diet'])}
className="input-field"
style={{ backgroundImage: 'none' }}
>
<option value="Halal">Halal</option>
<option value="Vegetarian">Vegetarian</option>
<option value="Vegan">Vegan</option>
</select>
</div>
{/* Allergies */}
<div className="section">
<h2 className="section-title">Allergens</h2>
<div className="checkbox-grid">
<label className="checkbox-label">
<input type="checkbox" checked={user.peanut} onChange={(e) => handleChange('peanut', e.target.checked)} /> Peanut
</label>
<label className="checkbox-label">
<input type="checkbox" checked={user.tree_nut} onChange={(e) => handleChange('tree_nut', e.target.checked)} /> Tree nut
</label>
<label className="checkbox-label">
<input type="checkbox" checked={user.dairy} onChange={(e) => handleChange('dairy', e.target.checked)} /> Dairy
</label>
<label className="checkbox-label">
<input type="checkbox" checked={user.gluten} onChange={(e) => handleChange('gluten', e.target.checked)} /> Gluten
</label>
<label className="checkbox-label">
<input type="checkbox" checked={user.egg} onChange={(e) => handleChange('egg', e.target.checked)} /> Egg
</label>
<label className="checkbox-label">
<input type="checkbox" checked={user.shellfish} onChange={(e) => handleChange('shellfish', e.target.checked)} /> Shellfish
</label>
<label className="checkbox-label">
<input type="checkbox" checked={user.sesame} onChange={(e) => handleChange('sesame', e.target.checked)} /> Sesame
</label>
<label className="checkbox-label">
<input type="checkbox" checked={user.soy} onChange={(e) => handleChange('soy', e.target.checked)} /> Soy
</label>
</div>
</div>
{/* Avoid / Sensitivities */}
<div className="section">
<h2 className="section-title">Sensitivities</h2>
<div className="checkbox-grid">
<label className="checkbox-label">
<input type="checkbox" checked={user.avoid_artificial_colors} onChange={(e) => handleChange('avoid_artificial_colors', e.target.checked)} /> Avoid artificial colors
</label>
<label className="checkbox-label">
<input type="checkbox" checked={user.avoid_artificial_sweeteners} onChange={(e) => handleChange('avoid_artificial_sweeteners', e.target.checked)} /> Avoid artificial sweeteners
</label>
<label className="checkbox-label">
<input type="checkbox" checked={user.avoid_ultra_processed} onChange={(e) => handleChange('avoid_ultra_processed', e.target.checked)} /> Avoid ultra-processed
</label>
<label className="checkbox-label">
<input type="checkbox" checked={user.caffeine_sensitive} onChange={(e) => handleChange('caffeine_sensitive', e.target.checked)} /> Caffeine sensitive
</label>
</div>
</div>
{/* My Flags */}
<div className="section">
<h2 className="section-title">My Flags</h2>
<div className="flag-input-group">
<input
type="text"
value={flagInput}
onChange={(e) => setFlagInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleAddFlag()
}
}}
className="input-field"
placeholder="Enter a flag and press Enter"
/>
</div>
<div className="flag-list">
{user.flags.map((flag, index) => (
<div key={index} className="flag-item">
<input
type="text"
value={flag}
onChange={(e) => handleUpdateFlag(index, e.target.value)}
className="flag-input"
/>
<button
className="flag-delete-btn"
onClick={() => handleDeleteFlag(index)}
>
✕
</button>
</div>
))}
</div>
</div>
<button className="save-btn">Save Changes</button>
</div>
</div>
)
}
This is UserProfile.tsx can you give me way according to this?
This is great! It’s much more detailed. We need to do three things to make this specific code work with the Camera and Backend:
Update
types.tsto match this exact structure.Modify
UserProfile.tsxslightly so it accepts data from the parent (CameraCapture) and can close itself.Update
CameraCapture.tsxto initialize this detailed state and send it to the backend.
Step 1: Update src/types.ts
Replace the old UserProfile interface with this detailed one so TypeScript knows about all the specific boolean flags.
// src/types.ts
// ... other types ...
export interface UserProfile {
goal: 'gain' | 'loss';
diet: 'Halal' | 'Vegetarian' | 'Vegan';
// Allergies
peanut: boolean;
tree_nut: boolean;
dairy: boolean;
gluten: boolean;
egg: boolean;
shellfish: boolean;
sesame: boolean;
soy: boolean;
// Sensitivities
avoid_artificial_colors: boolean;
avoid_artificial_sweeteners: boolean;
avoid_ultra_processed: boolean;
caffeine_sensitive: boolean;
// Custom Flags
flags: string[];
}
Step 2: Update src/components/UserProfile.tsx
I have kept your exact UI and logic, but added props so that CameraCapture can:
Pass in the current saved data (
initialData).Handle the "Save" action (
onSave).Handle the "Close" action (
onClose).
// src/components/UserProfile.tsx
import { useState } from 'react'
import './UserProfile.css'
import { UserProfile as UserProfileType } from '../types' // Import the type
interface Props {
initialData: UserProfileType;
onSave: (data: UserProfileType) => void;
onClose: () => void;
}
export default function UserProfile({ initialData, onSave, onClose }: Props) {
const [user, setUser] = useState<UserProfileType>(initialData)
const [flagInput, setFlagInput] = useState('')
const handleChange = (field: keyof UserProfileType, value: any) => {
setUser(prev => ({ ...prev, [field]: value }))
}
const handleAddFlag = () => {
if (flagInput.trim()) {
setUser(prev => ({ ...prev, flags: [...prev.flags, flagInput.trim()] }))
setFlagInput('')
}
}
const handleDeleteFlag = (index: number) => {
setUser(prev => ({ ...prev, flags: prev.flags.filter((_, i) => i !== index) }))
}
const handleUpdateFlag = (index: number, newValue: string) => {
setUser(prev => ({
...prev,
flags: prev.flags.map((flag, i) => (i === index ? newValue : flag))
}))
}
return (
// 1. WRAPPER: Handles the dark overlay background
<div className="profile-overlay">
{/* 2. CARD: Your original container */}
<div className="profile-container">
{/* Header with Close Button */}
<div style={{display: 'flex', justifyContent: 'space-between', alignItems: 'center'}}>
<h1 className="profile-title">My Profile 👤</h1>
<button onClick={onClose} style={{background:'none', border:'none', fontSize: '1.5rem', cursor:'pointer'}}>✕</button>
</div>
<div className="profile-card">
{/* Goal */}
<div className="form-group">
<label className="input-label">Goal</label>
<select
value={user.goal}
onChange={(e) => handleChange('goal', e.target.value)}
className="input-field"
>
<option value="gain">Gain</option>
<option value="loss">Loss</option>
</select>
</div>
{/* Diet Type */}
<div className="form-group">
<label className="input-label">Diet Type</label>
<select
value={user.diet}
onChange={(e) => handleChange('diet', e.target.value)}
className="input-field"
>
<option value="Halal">Halal</option>
<option value="Vegetarian">Vegetarian</option>
<option value="Vegan">Vegan</option>
</select>
</div>
{/* Allergies */}
<div className="section">
<h2 className="section-title">Allergens</h2>
<div className="checkbox-grid">
<label className="checkbox-label"><input type="checkbox" checked={user.peanut} onChange={(e) => handleChange('peanut', e.target.checked)} /> Peanut</label>
<label className="checkbox-label"><input type="checkbox" checked={user.tree_nut} onChange={(e) => handleChange('tree_nut', e.target.checked)} /> Tree nut</label>
<label className="checkbox-label"><input type="checkbox" checked={user.dairy} onChange={(e) => handleChange('dairy', e.target.checked)} /> Dairy</label>
<label className="checkbox-label"><input type="checkbox" checked={user.gluten} onChange={(e) => handleChange('gluten', e.target.checked)} /> Gluten</label>
<label className="checkbox-label"><input type="checkbox" checked={user.egg} onChange={(e) => handleChange('egg', e.target.checked)} /> Egg</label>
<label className="checkbox-label"><input type="checkbox" checked={user.shellfish} onChange={(e) => handleChange('shellfish', e.target.checked)} /> Shellfish</label>
<label className="checkbox-label"><input type="checkbox" checked={user.sesame} onChange={(e) => handleChange('sesame', e.target.checked)} /> Sesame</label>
<label className="checkbox-label"><input type="checkbox" checked={user.soy} onChange={(e) => handleChange('soy', e.target.checked)} /> Soy</label>
</div>
</div>
{/* Avoid / Sensitivities */}
<div className="section">
<h2 className="section-title">Sensitivities</h2>
<div className="checkbox-grid">
<label className="checkbox-label"><input type="checkbox" checked={user.avoid_artificial_colors} onChange={(e) => handleChange('avoid_artificial_colors', e.target.checked)} /> No Artif. Colors</label>
<label className="checkbox-label"><input type="checkbox" checked={user.avoid_artificial_sweeteners} onChange={(e) => handleChange('avoid_artificial_sweeteners', e.target.checked)} /> No Artif. Sweeteners</label>
<label className="checkbox-label"><input type="checkbox" checked={user.avoid_ultra_processed} onChange={(e) => handleChange('avoid_ultra_processed', e.target.checked)} /> No Ultra-Processed</label>
<label className="checkbox-label"><input type="checkbox" checked={user.caffeine_sensitive} onChange={(e) => handleChange('caffeine_sensitive', e.target.checked)} /> Caffeine Sensitive</label>
</div>
</div>
{/* My Flags */}
<div className="section">
<h2 className="section-title">My Flags</h2>
<div className="flag-input-group">
<input
type="text"
value={flagInput}
onChange={(e) => setFlagInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleAddFlag()
}}
className="input-field"
placeholder="Enter a flag..."
/>
<button onClick={handleAddFlag} className="add-flag-btn">+</button>
</div>
<div className="flag-list">
{user.flags.map((flag, index) => (
<div key={index} className="flag-item">
<span>{flag}</span>
<button className="flag-delete-btn" onClick={() => handleDeleteFlag(index)}>✕</button>
</div>
))}
</div>
</div>
{/* Actions */}
<div className="action-row">
<button className="save-btn" onClick={() => onSave(user)}>Save Changes</button>
</div>
</div>
</div>
</div>
)
}
Add this CSS to UserProfile.css to handle the overlay:
.profile-overlay {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: rgba(0,0,0,0.85); /* Dark background */
z-index: 1000;
display: flex;
justify-content: center;
align-items: center;
padding: 20px;
}
.profile-container {
background: white;
width: 100%;
max-width: 400px;
max-height: 90vh;
border-radius: 16px;
overflow-y: auto; /* Scroll inside the card */
padding: 20px;
box-shadow: 0 10px 30px rgba(0,0,0,0.3);
}
/* ... keep your existing styles below ... */
Step 3: Update src/components/CameraCapture.tsx
Finally, update the main component to initialize this complex state and pass it correctly.
// src/components/CameraCapture.tsx
import { useState, type ChangeEvent } from 'react'
import './CameraCapture.css'
import { type RealFoodAnalysis, type APIResponse, type PersonalFitAnalysis, type UserProfile } from '../types'
import AnalysisResults from './AnalysisResult'
import PersonalFitResult from './PersonalFitResult';
import UserProfilePage from './UserProfile'; // Import your updated component
type LensType = 'focus' | 'real_food' | 'personal';
export default function CameraCapture() {
const [image, setImage] = useState<string | null>(null)
// Data States
const [realFoodAnalysis, setRealFoodAnalysis] = useState<RealFoodAnalysis | null>(null)
const [personalFitAnalysis, setPersonalFitAnalysis] = useState<PersonalFitAnalysis | null>(null)
const [audioData, setAudioData] = useState<string | undefined>(undefined)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string>("")
const [selectedLens, setSelectedLens] = useState<LensType>('real_food')
// --- 1. INITIALIZE DETAILED PROFILE STATE ---
const [showProfile, setShowProfile] = useState(false);
const [userProfile, setUserProfile] = useState<UserProfile>({
goal: 'gain',
diet: 'Halal',
peanut: false,
tree_nut: false,
dairy: false,
gluten: false,
egg: false,
shellfish: false,
sesame: false,
soy: false,
avoid_artificial_colors: false,
avoid_artificial_sweeteners: false,
avoid_ultra_processed: false,
caffeine_sensitive: false,
flags: [],
});
const handleImageChange = async (e: ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files[0]) {
const file = e.target.files[0]
const url = URL.createObjectURL(file)
setImage(url)
setRealFoodAnalysis(null)
setPersonalFitAnalysis(null)
setError("")
setLoading(true)
const formData = new FormData()
formData.append("file", file)
formData.append("lens", selectedLens)
// --- 2. SEND FULL PROFILE TO BACKEND ---
if (selectedLens === 'personal') {
// This sends the entire object with all booleans to Python
formData.append("user_profile", JSON.stringify(userProfile));
}
try {
const response = await fetch("http://172.20.10.3:8000/api/analyze", {
method: "POST",
body: formData,
})
if (!response.ok) throw new Error("Backend failed");
const data: APIResponse = await response.json();
if (data.audio_base64) {
setAudioData(data.audio_base64);
}
let parsedAnalysis;
try {
parsedAnalysis = typeof data.health_analysis === 'string'
? JSON.parse(data.health_analysis)
: data.health_analysis;
} catch (e) {
console.error("JSON Parse Error:", e);
throw new Error("Could not read ingredients format.");
}
if (parsedAnalysis) {
if (selectedLens === 'personal') {
setPersonalFitAnalysis(parsedAnalysis as PersonalFitAnalysis);
} else {
setRealFoodAnalysis(parsedAnalysis as RealFoodAnalysis);
}
}
} catch (err) {
console.error(err);
setError("Error: Could not reach the brain! 🧠❌");
} finally {
setLoading(false);
}
}
}
const hasResults = (realFoodAnalysis !== null || personalFitAnalysis !== null) || loading;
return (
<div className="camera-container">
{/* --- 3. RENDER YOUR NEW PROFILE PAGE --- */}
{showProfile && (
<UserProfilePage
initialData={userProfile}
onClose={() => setShowProfile(false)}
onSave={(updatedProfile) => {
setUserProfile(updatedProfile);
setShowProfile(false);
}}
/>
)}
{/* --- 4. PROFILE BUTTON --- */}
{!image && (
<button className="profile-toggle-btn" onClick={() => setShowProfile(true)}>
{/* Show a dynamic summary, e.g., "Gain | Halal" */}
👤 {userProfile.goal.toUpperCase()} • {userProfile.diet}
</button>
)}
{/* ... Rest of your existing Camera UI ... */}
<div className={`sliding-wrapper ${hasResults ? 'show-results' : 'show-camera'}`}>
{/* ... image-area ... */}
<div className="image-area">
{/* ... lens overlay ... */}
{!image && (
<div className="lens-overlay">
<button
className={`lens-btn ${selectedLens === 'focus' ? 'active' : ''}`}
onClick={() => setSelectedLens('focus')}
>
<span className="lens-icon">🔍</span>
<span className="lens-label">Focus</span>
</button>
<button
className={`lens-btn ${selectedLens === 'real_food' ? 'active' : ''}`}
onClick={() => setSelectedLens('real_food')}
>
<span className="lens-icon">🥗</span>
<span className="lens-label">Real Food</span>
</button>
<button
className={`lens-btn ${selectedLens === 'personal' ? 'active' : ''}`}
onClick={() => setSelectedLens('personal')}
>
<span className="lens-icon">👤</span>
<span className="lens-label">Personal</span>
</button>
</div>
)}
{/* ... placeholder/image ... */}
{image ? (
<img src={image} alt="Captured" className="photo-preview" />
) : (
<div className="placeholder">
<div style={{fontSize: '3rem', marginBottom: '10px'}}>📸</div>
<p>Take a photo</p>
</div>
)}
</div>
{/* ... results-list ... */}
<div className="results-list">
{selectedLens === 'personal' ? (
<PersonalFitResult
data={personalFitAnalysis as any}
audio={audioData}
loading={loading}
error={error}
/>
) : (
<AnalysisResults
data={realFoodAnalysis as any}
audio={audioData}
loading={loading}
error={error}
/>
)}
</div>
</div>
<div className="controls-area">
<input
type="file"
accept="image/*"
capture="environment"
onChange={handleImageChange}
id="cameraInput"
style={{ display: 'none' }}
/>
<label htmlFor="cameraInput" className="camera-button">
{loading ? "Scanning..." : (hasResults ? "📸 Scan New" : "📸 Take Photo")}
</label>
</div>
</div>
)
}
i want to keep the UserProfile.tsx just like how it is so what should i do
If you want to keep your UserProfile.tsx exactly as you wrote it (logic, UI, and structure), we just need to add the "plumbing" so it can talk to the main Camera app.
We need to add props so it can:
Receive the current profile from
CameraCapture.Send the updated profile back when you click "Save".
Close the window.
Here is exactly what to do to make your code work without changing your design.
Step 1: Update src/types.ts
We need to make sure the global type definition matches the specific fields you created in your component.
// src/types.ts
// ... other types ...
export interface UserProfile {
goal: 'gain' | 'loss';
diet: 'Halal' | 'Vegetarian' | 'Vegan';
// Allergies
peanut: boolean;
tree_nut: boolean;
dairy: boolean;
gluten: boolean;
egg: boolean;
shellfish: boolean;
sesame: boolean;
soy: boolean;
// Sensitivities
avoid_artificial_colors: boolean;
avoid_artificial_sweeteners: boolean;
avoid_ultra_processed: boolean;
caffeine_sensitive: boolean;
// Flags
flags: string[];
}
Step 2: Minimal Update to UserProfile.tsx
I have kept your exact code. I only added the props part at the top and the onSave/onClose calls.
// src/components/UserProfile.tsx
import { useState } from 'react'
import './UserProfile.css'
import { UserProfile as UserData } from '../types' // Import the shared type
// 1. ADD PROPS INTERFACE
interface Props {
initialData: UserData;
onSave: (data: UserData) => void;
onClose: () => void;
}
// 2. ACCEPT PROPS
export default function UserProfile({ initialData, onSave, onClose }: Props) {
// 3. INITIALIZE STATE WITH PROP DATA
const [user, setUser] = useState<UserData>(initialData)
const [flagInput, setFlagInput] = useState('')
const handleChange = (field: keyof UserData, value: any) => {
setUser(prev => ({ ...prev, [field]: value }))
}
const handleAddFlag = () => {
if (flagInput.trim()) {
setUser(prev => ({ ...prev, flags: [...prev.flags, flagInput.trim()] }))
setFlagInput('')
}
}
const handleDeleteFlag = (index: number) => {
setUser(prev => ({ ...prev, flags: prev.flags.filter((_, i) => i !== index) }))
}
const handleUpdateFlag = (index: number, newValue: string) => {
setUser(prev => ({
...prev,
flags: prev.flags.map((flag, i) => (i === index ? newValue : flag))
}))
}
return (
// 4. WRAP IN OVERLAY (So it sits on top of camera)
<div className="profile-overlay">
<div className="profile-container">
{/* HEADER WITH CLOSE BUTTON */}
<div style={{display:'flex', justifyContent:'space-between', alignItems:'center'}}>
<h1 className="profile-title">My Profile 👤</h1>
<button onClick={onClose} style={{background:'none', border:'none', fontSize:'1.5rem', cursor:'pointer'}}>✕</button>
</div>
<div className="profile-card">
{/* Goal */}
<div className="form-group">
<label className="input-label">Goal</label>
<select
value={user.goal}
onChange={(e) => handleChange('goal', e.target.value as UserData['goal'])}
className="input-field"
style={{ backgroundImage: 'none' }}
>
<option value="gain">Gain</option>
<option value="loss">Loss</option>
</select>
</div>
{/* Diet Type */}
<div className="form-group">
<label className="input-label">Diet Type</label>
<select
value={user.diet}
onChange={(e) => handleChange('diet', e.target.value as UserData['diet'])}
className="input-field"
style={{ backgroundImage: 'none' }}
>
<option value="Halal">Halal</option>
<option value="Vegetarian">Vegetarian</option>
<option value="Vegan">Vegan</option>
</select>
</div>
{/* Allergies */}
<div className="section">
<h2 className="section-title">Allergens</h2>
<div className="checkbox-grid">
<label className="checkbox-label">
<input type="checkbox" checked={user.peanut} onChange={(e) => handleChange('peanut', e.target.checked)} /> Peanut
</label>
<label className="checkbox-label">
<input type="checkbox" checked={user.tree_nut} onChange={(e) => handleChange('tree_nut', e.target.checked)} /> Tree nut
</label>
<label className="checkbox-label">
<input type="checkbox" checked={user.dairy} onChange={(e) => handleChange('dairy', e.target.checked)} /> Dairy
</label>
<label className="checkbox-label">
<input type="checkbox" checked={user.gluten} onChange={(e) => handleChange('gluten', e.target.checked)} /> Gluten
</label>
<label className="checkbox-label">
<input type="checkbox" checked={user.egg} onChange={(e) => handleChange('egg', e.target.checked)} /> Egg
</label>
<label className="checkbox-label">
<input type="checkbox" checked={user.shellfish} onChange={(e) => handleChange('shellfish', e.target.checked)} /> Shellfish
</label>
<label className="checkbox-label">
<input type="checkbox" checked={user.sesame} onChange={(e) => handleChange('sesame', e.target.checked)} /> Sesame
</label>
<label className="checkbox-label">
<input type="checkbox" checked={user.soy} onChange={(e) => handleChange('soy', e.target.checked)} /> Soy
</label>
</div>
</div>
{/* Avoid / Sensitivities */}
<div className="section">
<h2 className="section-title">Sensitivities</h2>
<div className="checkbox-grid">
<label className="checkbox-label">
<input type="checkbox" checked={user.avoid_artificial_colors} onChange={(e) => handleChange('avoid_artificial_colors', e.target.checked)} /> Avoid artificial colors
</label>
<label className="checkbox-label">
<input type="checkbox" checked={user.avoid_artificial_sweeteners} onChange={(e) => handleChange('avoid_artificial_sweeteners', e.target.checked)} /> Avoid artificial sweeteners
</label>
<label className="checkbox-label">
<input type="checkbox" checked={user.avoid_ultra_processed} onChange={(e) => handleChange('avoid_ultra_processed', e.target.checked)} /> Avoid ultra-processed
</label>
<label className="checkbox-label">
<input type="checkbox" checked={user.caffeine_sensitive} onChange={(e) => handleChange('caffeine_sensitive', e.target.checked)} /> Caffeine sensitive
</label>
</div>
</div>
{/* My Flags */}
<div className="section">
<h2 className="section-title">My Flags</h2>
<div className="flag-input-group">
<input
type="text"
value={flagInput}
onChange={(e) => setFlagInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleAddFlag()
}
}}
className="input-field"
placeholder="Enter a flag and press Enter"
/>
</div>
<div className="flag-list">
{user.flags.map((flag, index) => (
<div key={index} className="flag-item">
<input
type="text"
value={flag}
onChange={(e) => handleUpdateFlag(index, e.target.value)}
className="flag-input"
/>
<button
className="flag-delete-btn"
onClick={() => handleDeleteFlag(index)}
>
✕
</button>
</div>
))}
</div>
</div>
{/* 5. CONNECT SAVE BUTTON */}
<button className="save-btn" onClick={() => onSave(user)}>Save Changes</button>
</div>
</div>
</div>
)
}
(Don't forget the CSS for the overlay!)
Add this to UserProfile.css:
.profile-overlay {
position: fixed;
top: 0; left: 0; width: 100vw; height: 100vh;
background: rgba(0,0,0,0.85);
z-index: 1000;
display: flex; justify-content: center; align-items: center;
padding: 20px;
}
.profile-container {
/* Ensure it has a white background if your original CSS didn't set it on container */
background: white;
border-radius: 16px;
max-height: 90vh;
overflow-y: auto;
width: 100%; max-width: 400px;
}
Step 3: Update src/components/CameraCapture.tsx
We just need to initialize the state with all those specific fields you defined.
// src/components/CameraCapture.tsx
import { useState, type ChangeEvent } from 'react'
import './CameraCapture.css'
import { type RealFoodAnalysis, type APIResponse, type PersonalFitAnalysis, type UserProfile } from '../types'
import AnalysisResults from './AnalysisResult'
import PersonalFitResult from './PersonalFitResult';
import UserProfilePage from './UserProfile';
type LensType = 'focus' | 'real_food' | 'personal';
export default function CameraCapture() {
const [image, setImage] = useState<string | null>(null)
// Data States
const [realFoodAnalysis, setRealFoodAnalysis] = useState<RealFoodAnalysis | null>(null)
const [personalFitAnalysis, setPersonalFitAnalysis] = useState<PersonalFitAnalysis | null>(null)
const [audioData, setAudioData] = useState<string | undefined>(undefined)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string>("")
const [selectedLens, setSelectedLens] = useState<LensType>('real_food')
// --- 1. USER PROFILE STATE (Matching your structure) ---
const [showProfile, setShowProfile] = useState(false);
const [userProfile, setUserProfile] = useState<UserProfile>({
goal: 'gain',
diet: 'Halal',
peanut: false,
tree_nut: false,
dairy: false,
gluten: false,
egg: false,
shellfish: false,
sesame: false,
soy: false,
avoid_artificial_colors: false,
avoid_artificial_sweeteners: false,
avoid_ultra_processed: false,
caffeine_sensitive: false,
flags: [],
});
const handleImageChange = async (e: ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files[0]) {
const file = e.target.files[0]
const url = URL.createObjectURL(file)
setImage(url)
setRealFoodAnalysis(null)
setPersonalFitAnalysis(null)
setError("")
setLoading(true)
const formData = new FormData()
formData.append("file", file)
formData.append("lens", selectedLens)
// --- 2. SEND THE FULL PROFILE ---
if (selectedLens === 'personal') {
formData.append("user_profile", JSON.stringify(userProfile));
}
try {
const response = await fetch("http://172.20.10.3:8000/api/analyze", {
method: "POST",
body: formData,
})
if (!response.ok) throw new Error("Backend failed");
const data: APIResponse = await response.json();
if (data.audio_base64) {
setAudioData(data.audio_base64);
}
let parsedAnalysis;
try {
parsedAnalysis = typeof data.health_analysis === 'string'
? JSON.parse(data.health_analysis)
: data.health_analysis;
} catch (e) {
console.error("JSON Parse Error:", e);
throw new Error("Could not read ingredients format.");
}
if (parsedAnalysis) {
if (selectedLens === 'personal') {
setPersonalFitAnalysis(parsedAnalysis as PersonalFitAnalysis);
} else {
setRealFoodAnalysis(parsedAnalysis as RealFoodAnalysis);
}
}
} catch (err) {
console.error(err);
setError("Error: Could not reach the brain! 🧠❌");
} finally {
setLoading(false);
}
}
}
const hasResults = (realFoodAnalysis !== null || personalFitAnalysis !== null) || loading;
return (
<div className="camera-container">
{/* --- 3. RENDER YOUR PROFILE --- */}
{showProfile && (
<UserProfilePage
initialData={userProfile}
onClose={() => setShowProfile(false)}
onSave={(updatedProfile) => {
setUserProfile(updatedProfile);
setShowProfile(false);
}}
/>
)}
{/* --- 4. PROFILE BUTTON --- */}
{!image && (
<button className="profile-toggle-btn" onClick={() => setShowProfile(true)}>
👤 {userProfile.goal.toUpperCase()} • {userProfile.diet}
</button>
)}
{/* ... The rest of your Slider / Camera UI ... */}
<div className={`sliding-wrapper ${hasResults ? 'show-results' : 'show-camera'}`}>
<div className="image-area">
{!image && (
<div className="lens-overlay">
<button className={`lens-btn ${selectedLens === 'focus' ? 'active' : ''}`} onClick={() => setSelectedLens('focus')}>
<span className="lens-icon">🔍</span> Focus
</button>
<button className={`lens-btn ${selectedLens === 'real_food' ? 'active' : ''}`} onClick={() => setSelectedLens('real_food')}>
<span className="lens-icon">🥗</span> Real Food
</button>
<button className={`lens-btn ${selectedLens === 'personal' ? 'active' : ''}`} onClick={() => setSelectedLens('personal')}>
<span className="lens-icon">👤</span> Personal
</button>
</div>
)}
{image ? (
<img src={image} alt="Captured" className="photo-preview" />
) : (
<div className="placeholder">
<div style={{fontSize: '3rem', marginBottom: '10px'}}>📸</div>
<p>Take a photo</p>
</div>
)}
</div>
<div className="results-list">
{selectedLens === 'personal' ? (
<PersonalFitResult
data={personalFitAnalysis as any}
audio={audioData}
loading={loading}
error={error}
/>
) : (
<AnalysisResults
data={realFoodAnalysis as any}
audio={audioData}
loading={loading}
error={error}
/>
)}
</div>
</div>
<div className="controls-area">
<input
type="file"
accept="image/*"
capture="environment"
onChange={handleImageChange}
id="cameraInput"
style={{ display: 'none' }}
/>
<label htmlFor="cameraInput" className="camera-button">
{loading ? "Scanning..." : (hasResults ? "📸 Scan New" : "📸 Take Photo")}
</label>
</div>
</div>
)
}
// src/components/UserProfile.tsx
import { useState } from 'react'
import './UserProfile.css' // <--- Import the new CSS file
export interface UserData {
goal: 'gain' | 'loss'
diet: 'Halal' | 'Vegetarian' | 'Vegan'
// Allergies
peanut: boolean
tree_nut: boolean
dairy: boolean
gluten: boolean
egg: boolean
shellfish: boolean
sesame: boolean
soy: boolean
// Avoid / sensitivities
avoid_artificial_colors: boolean
avoid_artificial_sweeteners: boolean
avoid_ultra_processed: boolean
caffeine_sensitive: boolean
// Flags
flags: string[]
}
export default function UserProfile() {
const [user, setUser] = useState<UserData>({
goal: 'gain',
diet: 'Halal',
peanut: false,
tree_nut: false,
dairy: false,
gluten: false,
egg: false,
shellfish: false,
sesame: false,
soy: false,
avoid_artificial_colors: false,
avoid_artificial_sweeteners: false,
avoid_ultra_processed: false,
caffeine_sensitive: false,
flags: [],
})
const [flagInput, setFlagInput] = useState('')
const handleChange = (field: keyof UserData, value: any) => {
setUser(prev => ({ ...prev, [field]: value }))
}
const handleAddFlag = () => {
if (flagInput.trim()) {
setUser(prev => ({ ...prev, flags: [...prev.flags, flagInput.trim()] }))
setFlagInput('')
}
}
const handleDeleteFlag = (index: number) => {
setUser(prev => ({ ...prev, flags: prev.flags.filter((_, i) => i !== index) }))
}
const handleUpdateFlag = (index: number, newValue: string) => {
setUser(prev => ({
...prev,
flags: prev.flags.map((flag, i) => (i === index ? newValue : flag))
}))
}
return (
<div className="profile-container">
<h1 className="profile-title">My Profile 👤</h1>
<div className="profile-card">
{/* Goal */}
<div className="form-group">
<label className="input-label">Goal</label>
<select
value={user.goal}
onChange={(e) => handleChange('goal', e.target.value as UserData['goal'])}
className="input-field"
style={{ backgroundImage: 'none' }}
>
<option value="gain">Gain</option>
<option value="loss">Loss</option>
</select>
</div>
{/* Diet Type */}
<div className="form-group">
<label className="input-label">Diet Type</label>
<select
value={user.diet}
onChange={(e) => handleChange('diet', e.target.value as UserData['diet'])}
className="input-field"
style={{ backgroundImage: 'none' }}
>
<option value="Halal">Halal</option>
<option value="Vegetarian">Vegetarian</option>
<option value="Vegan">Vegan</option>
</select>
</div>
{/* Allergies */}
<div className="section">
<h2 className="section-title">Allergens</h2>
<div className="checkbox-grid">
<label className="checkbox-label">
<input type="checkbox" checked={user.peanut} onChange={(e) => handleChange('peanut', e.target.checked)} /> Peanut
</label>
<label className="checkbox-label">
<input type="checkbox" checked={user.tree_nut} onChange={(e) => handleChange('tree_nut', e.target.checked)} /> Tree nut
</label>
<label className="checkbox-label">
<input type="checkbox" checked={user.dairy} onChange={(e) => handleChange('dairy', e.target.checked)} /> Dairy
</label>
<label className="checkbox-label">
<input type="checkbox" checked={user.gluten} onChange={(e) => handleChange('gluten', e.target.checked)} /> Gluten
</label>
<label className="checkbox-label">
<input type="checkbox" checked={user.egg} onChange={(e) => handleChange('egg', e.target.checked)} /> Egg
</label>
<label className="checkbox-label">
<input type="checkbox" checked={user.shellfish} onChange={(e) => handleChange('shellfish', e.target.checked)} /> Shellfish
</label>
<label className="checkbox-label">
<input type="checkbox" checked={user.sesame} onChange={(e) => handleChange('sesame', e.target.checked)} /> Sesame
</label>
<label className="checkbox-label">
<input type="checkbox" checked={user.soy} onChange={(e) => handleChange('soy', e.target.checked)} /> Soy
</label>
</div>
</div>
{/* Avoid / Sensitivities */}
<div className="section">
<h2 className="section-title">Sensitivities</h2>
<div className="checkbox-grid">
<label className="checkbox-label">
<input type="checkbox" checked={user.avoid_artificial_colors} onChange={(e) => handleChange('avoid_artificial_colors', e.target.checked)} /> Avoid artificial colors
</label>
<label className="checkbox-label">
<input type="checkbox" checked={user.avoid_artificial_sweeteners} onChange={(e) => handleChange('avoid_artificial_sweeteners', e.target.checked)} /> Avoid artificial sweeteners
</label>
<label className="checkbox-label">
<input type="checkbox" checked={user.avoid_ultra_processed} onChange={(e) => handleChange('avoid_ultra_processed', e.target.checked)} /> Avoid ultra-processed
</label>
<label className="checkbox-label">
<input type="checkbox" checked={user.caffeine_sensitive} onChange={(e) => handleChange('caffeine_sensitive', e.target.checked)} /> Caffeine sensitive
</label>
</div>
</div>
{/* My Flags */}
<div className="section">
<h2 className="section-title">My Flags</h2>
<div className="flag-input-group">
<input
type="text"
value={flagInput}
onChange={(e) => setFlagInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleAddFlag()
}
}}
className="input-field"
placeholder="Enter a flag and press Enter"
/>
</div>
<div className="flag-list">
{user.flags.map((flag, index) => (
<div key={index} className="flag-item">
<input
type="text"
value={flag}
onChange={(e) => handleUpdateFlag(index, e.target.value)}
className="flag-input"
/>
<button
className="flag-delete-btn"
onClick={() => handleDeleteFlag(index)}
>
✕
</button>
</div>
))}
</div>
</div>
<button className="save-btn">Save Changes</button>
</div>
</div>
)
}
How can I share the export interface UserData to the CameraCapture without changing anything from the file?
You can share the UserData interface by using a Named Import in your CameraCapture.tsx file.
Since you exported the interface in UserProfile.tsx using export interface UserData, you can import it in other files using curly braces { }.
Here is the updated src/components/CameraCapture.tsx.
Key Changes Made:
Import Line: Changed to
import UserProfile, { type UserData } from './UserProfile'.State Definition: Used
useState<UserData>and initialized it with the exact matching defaults from your profile file.
// src/components/CameraCapture.tsx
import { useState, type ChangeEvent } from 'react'
import './CameraCapture.css'
import { type RealFoodAnalysis, type APIResponse, type PersonalFitAnalysis } from '../types'
import AnalysisResults from './AnalysisResult'
import PersonalFitResult from './PersonalFitResult';
// 👇 IMPORT THE COMPONENT AND THE TYPE FROM THE SAME FILE
import UserProfile, { type UserData } from './UserProfile';
type LensType = 'focus' | 'real_food' | 'personal';
export default function CameraCapture() {
const [image, setImage] = useState<string | null>(null)
// Data States
const [realFoodAnalysis, setRealFoodAnalysis] = useState<RealFoodAnalysis | null>(null)
const [personalFitAnalysis, setPersonalFitAnalysis] = useState<PersonalFitAnalysis | null>(null)
const [audioData, setAudioData] = useState<string | undefined>(undefined)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string>("")
const [selectedLens, setSelectedLens] = useState<LensType>('real_food')
// --- 1. USER PROFILE STATE ---
const [showProfile, setShowProfile] = useState(false);
// We use the imported 'UserData' type here
const [userProfile, setUserProfile] = useState<UserData>({
goal: 'gain',
diet: 'Halal',
peanut: false,
tree_nut: false,
dairy: false,
gluten: false,
egg: false,
shellfish: false,
sesame: false,
soy: false,
avoid_artificial_colors: false,
avoid_artificial_sweeteners: false,
avoid_ultra_processed: false,
caffeine_sensitive: false,
flags: [],
});
const handleImageChange = async (e: ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files[0]) {
const file = e.target.files[0]
const url = URL.createObjectURL(file)
setImage(url)
setRealFoodAnalysis(null)
setPersonalFitAnalysis(null)
setError("")
setLoading(true)
const formData = new FormData()
formData.append("file", file)
formData.append("lens", selectedLens)
// --- 2. SEND PROFILE TO BACKEND ---
if (selectedLens === 'personal') {
// This sends the full UserData object to Python
formData.append("user_profile", JSON.stringify(userProfile));
}
try {
const response = await fetch("http://172.20.10.3:8000/api/analyze", {
method: "POST",
body: formData,
})
if (!response.ok) throw new Error("Backend failed");
const data: APIResponse = await response.json();
if (data.audio_base64) {
setAudioData(data.audio_base64);
}
let parsedAnalysis;
try {
parsedAnalysis = typeof data.health_analysis === 'string'
? JSON.parse(data.health_analysis)
: data.health_analysis;
} catch (e) {
console.error("JSON Parse Error:", e);
throw new Error("Could not read ingredients format.");
}
if (parsedAnalysis) {
if (selectedLens === 'personal') {
setPersonalFitAnalysis(parsedAnalysis as PersonalFitAnalysis);
} else {
setRealFoodAnalysis(parsedAnalysis as RealFoodAnalysis);
}
}
} catch (err) {
console.error(err);
setError("Error: Could not reach the brain! 🧠❌");
} finally {
setLoading(false);
}
}
}
const hasResults = (realFoodAnalysis !== null || personalFitAnalysis !== null) || loading;
return (
<div className="camera-container">
{/* --- 3. RENDER PROFILE PAGE (Overlay) --- */}
{showProfile && (
<div style={{
position: 'fixed', top: 0, left: 0, width: '100%', height: '100%',
zIndex: 1000, background: 'rgba(0,0,0,0.8)', display: 'flex', justifyContent: 'center', alignItems: 'center'
}}>
{/* ⚠️ IMPORTANT NOTE:
Since you requested NOT to change UserProfile.tsx,
it currently does not accept 'onSave' or 'initialData' props.
This means the data you type inside UserProfile
WILL NOT AUTOMATICALLY UPDATE 'userProfile' in CameraCapture
until we update UserProfile.tsx to accept props.
*/}
<div style={{ position: 'relative', width: '100%', maxHeight: '90vh' }}>
<button
onClick={() => setShowProfile(false)}
style={{ position: 'absolute', top: 20, right: 20, zIndex: 1001, fontSize: '2rem', background: 'none', border: 'none', cursor: 'pointer'}}
>
❌
</button>
<UserProfile />
</div>
</div>
)}
{/* --- 4. PROFILE BUTTON --- */}
{!image && (
<button className="profile-toggle-btn" onClick={() => setShowProfile(true)}>
👤 {userProfile.goal.toUpperCase()} • {userProfile.diet}
</button>
)}
<div className={`sliding-wrapper ${hasResults ? 'show-results' : 'show-camera'}`}>
{/* LEFT SIDE: Camera */}
<div className="image-area">
{!image && (
<div className="lens-overlay">
<button
className={`lens-btn ${selectedLens === 'focus' ? 'active' : ''}`}
onClick={() => setSelectedLens('focus')}
>
<span className="lens-icon">🔍</span>
<span className="lens-label">Focus</span>
</button>
<button
className={`lens-btn ${selectedLens === 'real_food' ? 'active' : ''}`}
onClick={() => setSelectedLens('real_food')}
>
<span className="lens-icon">🥗</span>
<span className="lens-label">Real Food</span>
</button>
<button
className={`lens-btn ${selectedLens === 'personal' ? 'active' : ''}`}
onClick={() => setSelectedLens('personal')}
>
<span className="lens-icon">👤</span>
<span className="lens-label">Personal</span>
</button>
</div>
)}
{image ? (
<img src={image} alt="Captured" className="photo-preview" />
) : (
<div className="placeholder">
<div style={{fontSize: '3rem', marginBottom: '10px'}}>📸</div>
<p>Take a photo</p>
</div>
)}
</div>
{/* RIGHT SIDE: Results */}
<div className="results-list">
{selectedLens === 'personal' ? (
<PersonalFitResult
data={personalFitAnalysis as any}
audio={audioData}
loading={loading}
error={error}
/>
) : (
<AnalysisResults
data={realFoodAnalysis as any}
audio={audioData}
loading={loading}
error={error}
/>
)}
</div>
</div>
<div className="controls-area">
<input
type="file"
accept="image/*"
capture="environment"
onChange={handleImageChange}
id="cameraInput"
style={{ display: 'none' }}
/>
<label htmlFor="cameraInput" className="camera-button">
{loading ? "Scanning..." : (hasResults ? "📸 Scan New" : "📸 Take Photo")}
</label>
</div>
</div>
)
}
// src/components/UserProfile.tsx
import { useState } from 'react'
import './UserProfile.css' // <--- Import the new CSS file
export interface UserData {
goal: 'gain' | 'loss'
diet: 'Halal' | 'Vegetarian' | 'Vegan'
// Allergies
peanut: boolean
tree_nut: boolean
dairy: boolean
gluten: boolean
egg: boolean
shellfish: boolean
sesame: boolean
soy: boolean
// Avoid / sensitivities
avoid_artificial_colors: boolean
avoid_artificial_sweeteners: boolean
avoid_ultra_processed: boolean
caffeine_sensitive: boolean
// Flags
flags: string[]
}
export default function UserProfile() {
const [user, setUser] = useState<UserData>({
goal: 'gain',
diet: 'Halal',
peanut: false,
tree_nut: false,
dairy: false,
gluten: false,
egg: false,
shellfish: false,
sesame: false,
soy: false,
avoid_artificial_colors: false,
avoid_artificial_sweeteners: false,
avoid_ultra_processed: false,
caffeine_sensitive: false,
flags: [],
})
const [flagInput, setFlagInput] = useState('')
const handleChange = (field: keyof UserData, value: any) => {
setUser(prev => ({ ...prev, [field]: value }))
}
const handleAddFlag = () => {
if (flagInput.trim()) {
setUser(prev => ({ ...prev, flags: [...prev.flags, flagInput.trim()] }))
setFlagInput('')
}
}
const handleDeleteFlag = (index: number) => {
setUser(prev => ({ ...prev, flags: prev.flags.filter((_, i) => i !== index) }))
}
const handleUpdateFlag = (index: number, newValue: string) => {
setUser(prev => ({
...prev,
flags: prev.flags.map((flag, i) => (i === index ? newValue : flag))
}))
}
return (
<div className="profile-container">
<h1 className="profile-title">My Profile 👤</h1>
<div className="profile-card">
{/* Goal */}
<div className="form-group">
<label className="input-label">Goal</label>
<select
value={user.goal}
onChange={(e) => handleChange('goal', e.target.value as UserData['goal'])}
className="input-field"
style={{ backgroundImage: 'none' }}
>
<option value="gain">Gain</option>
<option value="loss">Loss</option>
</select>
</div>
{/* Diet Type */}
<div className="form-group">
<label className="input-label">Diet Type</label>
<select
value={user.diet}
onChange={(e) => handleChange('diet', e.target.value as UserData['diet'])}
className="input-field"
style={{ backgroundImage: 'none' }}
>
<option value="Halal">Halal</option>
<option value="Vegetarian">Vegetarian</option>
<option value="Vegan">Vegan</option>
</select>
</div>
{/* Allergies */}
<div className="section">
<h2 className="section-title">Allergens</h2>
<div className="checkbox-grid">
<label className="checkbox-label">
<input type="checkbox" checked={user.peanut} onChange={(e) => handleChange('peanut', e.target.checked)} /> Peanut
</label>
<label className="checkbox-label">
<input type="checkbox" checked={user.tree_nut} onChange={(e) => handleChange('tree_nut', e.target.checked)} /> Tree nut
</label>
<label className="checkbox-label">
<input type="checkbox" checked={user.dairy} onChange={(e) => handleChange('dairy', e.target.checked)} /> Dairy
</label>
<label className="checkbox-label">
<input type="checkbox" checked={user.gluten} onChange={(e) => handleChange('gluten', e.target.checked)} /> Gluten
</label>
<label className="checkbox-label">
<input type="checkbox" checked={user.egg} onChange={(e) => handleChange('egg', e.target.checked)} /> Egg
</label>
<label className="checkbox-label">
<input type="checkbox" checked={user.shellfish} onChange={(e) => handleChange('shellfish', e.target.checked)} /> Shellfish
</label>
<label className="checkbox-label">
<input type="checkbox" checked={user.sesame} onChange={(e) => handleChange('sesame', e.target.checked)} /> Sesame
</label>
<label className="checkbox-label">
<input type="checkbox" checked={user.soy} onChange={(e) => handleChange('soy', e.target.checked)} /> Soy
</label>
</div>
</div>
{/* Avoid / Sensitivities */}
<div className="section">
<h2 className="section-title">Sensitivities</h2>
<div className="checkbox-grid">
<label className="checkbox-label">
<input type="checkbox" checked={user.avoid_artificial_colors} onChange={(e) => handleChange('avoid_artificial_colors', e.target.checked)} /> Avoid artificial colors
</label>
<label className="checkbox-label">
<input type="checkbox" checked={user.avoid_artificial_sweeteners} onChange={(e) => handleChange('avoid_artificial_sweeteners', e.target.checked)} /> Avoid artificial sweeteners
</label>
<label className="checkbox-label">
<input type="checkbox" checked={user.avoid_ultra_processed} onChange={(e) => handleChange('avoid_ultra_processed', e.target.checked)} /> Avoid ultra-processed
</label>
<label className="checkbox-label">
<input type="checkbox" checked={user.caffeine_sensitive} onChange={(e) => handleChange('caffeine_sensitive', e.target.checked)} /> Caffeine sensitive
</label>
</div>
</div>
{/* My Flags */}
<div className="section">
<h2 className="section-title">My Flags</h2>
<div className="flag-input-group">
<input
type="text"
value={flagInput}
onChange={(e) => setFlagInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleAddFlag()
}
}}
className="input-field"
placeholder="Enter a flag and press Enter"
/>
</div>
<div className="flag-list">
{user.flags.map((flag, index) => (
<div key={index} className="flag-item">
<input
type="text"
value={flag}
onChange={(e) => handleUpdateFlag(index, e.target.value)}
className="flag-input"
/>
<button
className="flag-delete-btn"
onClick={() => handleDeleteFlag(index)}
>
✕
</button>
</div>
))}
</div>
</div>
<button className="save-btn">Save Changes</button>
</div>
</div>
)
}
Can you implemenet setUserProfile when save change is clicked
To implement the "Save" functionality, we need to pass a function from the parent (CameraCapture) down to this component (UserProfile). This allows the child component to update the parent's state.
Here is the updated src/components/UserProfile.tsx.
Key Changes:
Added
Propsinterface: DefinesonSave,onClose, andinitialData.Updated
useState: Initializes withinitialDatapassed from the parent (so it remembers your previous settings).Connected the Button: The "Save Changes" button now calls
onSave(user)andonClose().
// src/components/UserProfile.tsx
import { useState } from 'react'
import './UserProfile.css'
export interface UserData {
goal: 'gain' | 'loss'
diet: 'Halal' | 'Vegetarian' | 'Vegan'
peanut: boolean
tree_nut: boolean
dairy: boolean
gluten: boolean
egg: boolean
shellfish: boolean
sesame: boolean
soy: boolean
avoid_artificial_colors: boolean
avoid_artificial_sweeteners: boolean
avoid_ultra_processed: boolean
caffeine_sensitive: boolean
flags: string[]
}
// 1. DEFINING PROPS so parent can pass functions
interface Props {
initialData: UserData; // Data to show when opening
onSave: (data: UserData) => void; // Function to update parent state
onClose: () => void; // Function to close the modal
}
// 2. ACCEPTING PROPS
export default function UserProfile({ initialData, onSave, onClose }: Props) {
// 3. INITIALIZE STATE with the data passed from parent
const [user, setUser] = useState<UserData>(initialData)
const [flagInput, setFlagInput] = useState('')
const handleChange = (field: keyof UserData, value: any) => {
setUser(prev => ({ ...prev, [field]: value }))
}
const handleAddFlag = () => {
if (flagInput.trim()) {
setUser(prev => ({ ...prev, flags: [...prev.flags, flagInput.trim()] }))
setFlagInput('')
}
}
const handleDeleteFlag = (index: number) => {
setUser(prev => ({ ...prev, flags: prev.flags.filter((_, i) => i !== index) }))
}
const handleUpdateFlag = (index: number, newValue: string) => {
setUser(prev => ({
...prev,
flags: prev.flags.map((flag, i) => (i === index ? newValue : flag))
}))
}
// 4. THE SAVE HANDLER
const handleSave = () => {
onSave(user); // Send data back to CameraCapture
onClose(); // Close the window
}
return (
<div className="profile-container">
{/* Header with Close Button */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<h1 className="profile-title">My Profile 👤</h1>
<button
onClick={onClose}
style={{ background: 'none', border: 'none', fontSize: '1.5rem', cursor: 'pointer' }}
>
✕
</button>
</div>
<div className="profile-card">
{/* Goal */}
<div className="form-group">
<label className="input-label">Goal</label>
<select
value={user.goal}
onChange={(e) => handleChange('goal', e.target.value as UserData['goal'])}
className="input-field"
style={{ backgroundImage: 'none' }}
>
<option value="gain">Gain</option>
<option value="loss">Loss</option>
</select>
</div>
{/* Diet Type */}
<div className="form-group">
<label className="input-label">Diet Type</label>
<select
value={user.diet}
onChange={(e) => handleChange('diet', e.target.value as UserData['diet'])}
className="input-field"
style={{ backgroundImage: 'none' }}
>
<option value="Halal">Halal</option>
<option value="Vegetarian">Vegetarian</option>
<option value="Vegan">Vegan</option>
</select>
</div>
{/* Allergies */}
<div className="section">
<h2 className="section-title">Allergens</h2>
<div className="checkbox-grid">
<label className="checkbox-label">
<input type="checkbox" checked={user.peanut} onChange={(e) => handleChange('peanut', e.target.checked)} /> Peanut
</label>
<label className="checkbox-label">
<input type="checkbox" checked={user.tree_nut} onChange={(e) => handleChange('tree_nut', e.target.checked)} /> Tree nut
</label>
<label className="checkbox-label">
<input type="checkbox" checked={user.dairy} onChange={(e) => handleChange('dairy', e.target.checked)} /> Dairy
</label>
<label className="checkbox-label">
<input type="checkbox" checked={user.gluten} onChange={(e) => handleChange('gluten', e.target.checked)} /> Gluten
</label>
<label className="checkbox-label">
<input type="checkbox" checked={user.egg} onChange={(e) => handleChange('egg', e.target.checked)} /> Egg
</label>
<label className="checkbox-label">
<input type="checkbox" checked={user.shellfish} onChange={(e) => handleChange('shellfish', e.target.checked)} /> Shellfish
</label>
<label className="checkbox-label">
<input type="checkbox" checked={user.sesame} onChange={(e) => handleChange('sesame', e.target.checked)} /> Sesame
</label>
<label className="checkbox-label">
<input type="checkbox" checked={user.soy} onChange={(e) => handleChange('soy', e.target.checked)} /> Soy
</label>
</div>
</div>
{/* Avoid / Sensitivities */}
<div className="section">
<h2 className="section-title">Sensitivities</h2>
<div className="checkbox-grid">
<label className="checkbox-label">
<input type="checkbox" checked={user.avoid_artificial_colors} onChange={(e) => handleChange('avoid_artificial_colors', e.target.checked)} /> Avoid artificial colors
</label>
<label className="checkbox-label">
<input type="checkbox" checked={user.avoid_artificial_sweeteners} onChange={(e) => handleChange('avoid_artificial_sweeteners', e.target.checked)} /> Avoid artificial sweeteners
</label>
<label className="checkbox-label">
<input type="checkbox" checked={user.avoid_ultra_processed} onChange={(e) => handleChange('avoid_ultra_processed', e.target.checked)} /> Avoid ultra-processed
</label>
<label className="checkbox-label">
<input type="checkbox" checked={user.caffeine_sensitive} onChange={(e) => handleChange('caffeine_sensitive', e.target.checked)} /> Caffeine sensitive
</label>
</div>
</div>
{/* My Flags */}
<div className="section">
<h2 className="section-title">My Flags</h2>
<div className="flag-input-group">
<input
type="text"
value={flagInput}
onChange={(e) => setFlagInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleAddFlag()
}
}}
className="input-field"
placeholder="Enter a flag and press Enter"
/>
</div>
<div className="flag-list">
{user.flags.map((flag, index) => (
<div key={index} className="flag-item">
<input
type="text"
value={flag}
onChange={(e) => handleUpdateFlag(index, e.target.value)}
className="flag-input"
/>
<button
className="flag-delete-btn"
onClick={() => handleDeleteFlag(index)}
>
✕
</button>
</div>
))}
</div>
</div>
{/* 5. CONNECTED SAVE BUTTON */}
<button className="save-btn" onClick={handleSave}>
Save Changes
</button>
</div>
</div>
)
}
How to use this in CameraCapture.tsx
Make sure your render logic in CameraCapture.tsx looks like this to pass the props correctly:
{showProfile && (
<div style={{...overlayStyles...}}>
<UserProfile
initialData={userProfile}
onClose={() => setShowProfile(false)}
onSave={(updatedData) => {
setUserProfile(updatedData); // This updates the state in CameraCapture
setShowProfile(false); // This closes the modal
}}
/>
</div>
)}
CAN YOU give me updated version of this
// src/components/UserProfile.tsx
import { useState } from 'react'
import './UserProfile.css' // <--- Import the new CSS file
export interface UserData {
goal: 'gain' | 'loss'
diet: 'Halal' | 'Vegetarian' | 'Vegan'
// Allergies
peanut: boolean
tree_nut: boolean
dairy: boolean
gluten: boolean
egg: boolean
shellfish: boolean
sesame: boolean
soy: boolean
// Avoid / sensitivities
avoid_artificial_colors: boolean
avoid_artificial_sweeteners: boolean
avoid_ultra_processed: boolean
caffeine_sensitive: boolean
// Flags
flags: string[]
}
interface Props {
initialData: UserData; // Data to show when opening
onSave: (data: UserData) => void; // Function to update parent state
onClose: () => void; // Function to close the modal
}
export default function UserProfile({ initialData, onSave, onClose }: Props) {
const [user, setUser] = useState<UserData>(initialData)
const [flagInput, setFlagInput] = useState('')
const handleChange = (field: keyof UserData, value: any) => {
setUser(prev => ({ ...prev, [field]: value }))
}
const handleAddFlag = () => {
if (flagInput.trim()) {
setUser(prev => ({ ...prev, flags: [...prev.flags, flagInput.trim()] }))
setFlagInput('')
}
}
const handleDeleteFlag = (index: number) => {
setUser(prev => ({ ...prev, flags: prev.flags.filter((_, i) => i !== index) }))
}
const handleUpdateFlag = (index: number, newValue: string) => {
setUser(prev => ({
...prev,
flags: prev.flags.map((flag, i) => (i === index ? newValue : flag))
}))
}
// THE SAVE HANDLER
const handleSave = () => {
onSave(user); // Send data back to CameraCapture
onClose(); // Close the window
}
return (
<div className="profile-container">
<h1 className="profile-title">My Profile 👤</h1>
<div className="profile-card">
{/* Goal */}
<div className="form-group">
<label className="input-label">Goal</label>
<select
value={user.goal}
onChange={(e) => handleChange('goal', e.target.value as UserData['goal'])}
className="input-field"
style={{ backgroundImage: 'none' }}
>
<option value="gain">Gain</option>
<option value="loss">Loss</option>
</select>
</div>
{/* Diet Type */}
<div className="form-group">
<label className="input-label">Diet Type</label>
<select
value={user.diet}
onChange={(e) => handleChange('diet', e.target.value as UserData['diet'])}
className="input-field"
style={{ backgroundImage: 'none' }}
>
<option value="Halal">Halal</option>
<option value="Vegetarian">Vegetarian</option>
<option value="Vegan">Vegan</option>
</select>
</div>
{/* Allergies */}
<div className="section">
<h2 className="section-title">Allergens</h2>
<div className="checkbox-grid">
<label className="checkbox-label">
<input type="checkbox" checked={user.peanut} onChange={(e) => handleChange('peanut', e.target.checked)} /> Peanut
</label>
<label className="checkbox-label">
<input type="checkbox" checked={user.tree_nut} onChange={(e) => handleChange('tree_nut', e.target.checked)} /> Tree nut
</label>
<label className="checkbox-label">
<input type="checkbox" checked={user.dairy} onChange={(e) => handleChange('dairy', e.target.checked)} /> Dairy
</label>
<label className="checkbox-label">
<input type="checkbox" checked={user.gluten} onChange={(e) => handleChange('gluten', e.target.checked)} /> Gluten
</label>
<label className="checkbox-label">
<input type="checkbox" checked={user.egg} onChange={(e) => handleChange('egg', e.target.checked)} /> Egg
</label>
<label className="checkbox-label">
<input type="checkbox" checked={user.shellfish} onChange={(e) => handleChange('shellfish', e.target.checked)} /> Shellfish
</label>
<label className="checkbox-label">
<input type="checkbox" checked={user.sesame} onChange={(e) => handleChange('sesame', e.target.checked)} /> Sesame
</label>
<label className="checkbox-label">
<input type="checkbox" checked={user.soy} onChange={(e) => handleChange('soy', e.target.checked)} /> Soy
</label>
</div>
</div>
{/* Avoid / Sensitivities */}
<div className="section">
<h2 className="section-title">Sensitivities</h2>
<div className="checkbox-grid">
<label className="checkbox-label">
<input type="checkbox" checked={user.avoid_artificial_colors} onChange={(e) => handleChange('avoid_artificial_colors', e.target.checked)} /> Avoid artificial colors
</label>
<label className="checkbox-label">
<input type="checkbox" checked={user.avoid_artificial_sweeteners} onChange={(e) => handleChange('avoid_artificial_sweeteners', e.target.checked)} /> Avoid artificial sweeteners
</label>
<label className="checkbox-label">
<input type="checkbox" checked={user.avoid_ultra_processed} onChange={(e) => handleChange('avoid_ultra_processed', e.target.checked)} /> Avoid ultra-processed
</label>
<label className="checkbox-label">
<input type="checkbox" checked={user.caffeine_sensitive} onChange={(e) => handleChange('caffeine_sensitive', e.target.checked)} /> Caffeine sensitive
</label>
</div>
</div>
{/* My Flags */}
<div className="section">
<h2 className="section-title">My Flags</h2>
<div className="flag-input-group">
<input
type="text"
value={flagInput}
onChange={(e) => setFlagInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleAddFlag()
}
}}
className="input-field"
placeholder="Enter a flag and press Enter"
/>
</div>
<div className="flag-list">
{user.flags.map((flag, index) => (
<div key={index} className="flag-item">
<input
type="text"
value={flag}
onChange={(e) => handleUpdateFlag(index, e.target.value)}
className="flag-input"
/>
<button
className="flag-delete-btn"
onClick={() => handleDeleteFlag(index)}
>
✕
</button>
</div>
))}
</div>
</div>
<button className="save-btn" onClick={handleSave}>Save Changes</button>
</div>
</div>
)
}
Here is the updated src/components/UserProfile.tsx.
I have improved the structure to include a dark overlay background, a close button (X) in the top right, and ensured the Save button triggers the update back to your camera component.
src/components/UserProfile.tsx
import { useState } from 'react'
import './UserProfile.css'
export interface UserData {
goal: 'gain' | 'loss'
diet: 'Halal' | 'Vegetarian' | 'Vegan'
// Allergies
peanut: boolean
tree_nut: boolean
dairy: boolean
gluten: boolean
egg: boolean
shellfish: boolean
sesame: boolean
soy: boolean
// Avoid / sensitivities
avoid_artificial_colors: boolean
avoid_artificial_sweeteners: boolean
avoid_ultra_processed: boolean
caffeine_sensitive: boolean
// Flags
flags: string[]
}
interface Props {
initialData: UserData;
onSave: (data: UserData) => void;
onClose: () => void;
}
export default function UserProfile({ initialData, onSave, onClose }: Props) {
const [user, setUser] = useState<UserData>(initialData)
const [flagInput, setFlagInput] = useState('')
const handleChange = (field: keyof UserData, value: any) => {
setUser(prev => ({ ...prev, [field]: value }))
}
const handleAddFlag = () => {
if (flagInput.trim()) {
setUser(prev => ({ ...prev, flags: [...prev.flags, flagInput.trim()] }))
setFlagInput('')
}
}
const handleDeleteFlag = (index: number) => {
setUser(prev => ({ ...prev, flags: prev.flags.filter((_, i) => i !== index) }))
}
const handleUpdateFlag = (index: number, newValue: string) => {
setUser(prev => ({
...prev,
flags: prev.flags.map((flag, i) => (i === index ? newValue : flag))
}))
}
// THE SAVE HANDLER
const handleSave = () => {
onSave(user); // 1. Update Parent State
onClose(); // 2. Close Modal
}
return (
// 1. OVERLAY WRAPPER (Centers the modal)
<div className="profile-overlay">
{/* 2. MODAL CARD */}
<div className="profile-container">
{/* HEADER: Title + Close Button */}
<div className="profile-header">
<h1 className="profile-title">My Profile 👤</h1>
<button className="close-icon-btn" onClick={onClose}>✕</button>
</div>
<div className="profile-content">
{/* Goal */}
<div className="form-group">
<label className="input-label">Goal</label>
<select
value={user.goal}
onChange={(e) => handleChange('goal', e.target.value as UserData['goal'])}
className="input-field"
>
<option value="gain">Gain Muscle / Weight</option>
<option value="loss">Lose Weight / Cut</option>
</select>
</div>
{/* Diet Type */}
<div className="form-group">
<label className="input-label">Diet Type</label>
<select
value={user.diet}
onChange={(e) => handleChange('diet', e.target.value as UserData['diet'])}
className="input-field"
>
<option value="Halal">Halal</option>
<option value="Vegetarian">Vegetarian</option>
<option value="Vegan">Vegan</option>
</select>
</div>
{/* Allergies */}
<div className="section">
<h2 className="section-title">Allergens (I avoid...)</h2>
<div className="checkbox-grid">
<label className="checkbox-label">
<input type="checkbox" checked={user.peanut} onChange={(e) => handleChange('peanut', e.target.checked)} /> Peanut
</label>
<label className="checkbox-label">
<input type="checkbox" checked={user.tree_nut} onChange={(e) => handleChange('tree_nut', e.target.checked)} /> Tree nut
</label>
<label className="checkbox-label">
<input type="checkbox" checked={user.dairy} onChange={(e) => handleChange('dairy', e.target.checked)} /> Dairy
</label>
<label className="checkbox-label">
<input type="checkbox" checked={user.gluten} onChange={(e) => handleChange('gluten', e.target.checked)} /> Gluten
</label>
<label className="checkbox-label">
<input type="checkbox" checked={user.egg} onChange={(e) => handleChange('egg', e.target.checked)} /> Egg
</label>
<label className="checkbox-label">
<input type="checkbox" checked={user.shellfish} onChange={(e) => handleChange('shellfish', e.target.checked)} /> Shellfish
</label>
<label className="checkbox-label">
<input type="checkbox" checked={user.sesame} onChange={(e) => handleChange('sesame', e.target.checked)} /> Sesame
</label>
<label className="checkbox-label">
<input type="checkbox" checked={user.soy} onChange={(e) => handleChange('soy', e.target.checked)} /> Soy
</label>
</div>
</div>
{/* Avoid / Sensitivities */}
<div className="section">
<h2 className="section-title">Sensitivities</h2>
<div className="checkbox-grid">
<label className="checkbox-label">
<input type="checkbox" checked={user.avoid_artificial_colors} onChange={(e) => handleChange('avoid_artificial_colors', e.target.checked)} /> No Artificial Colors
</label>
<label className="checkbox-label">
<input type="checkbox" checked={user.avoid_artificial_sweeteners} onChange={(e) => handleChange('avoid_artificial_sweeteners', e.target.checked)} /> No Artificial Sweeteners
</label>
<label className="checkbox-label">
<input type="checkbox" checked={user.avoid_ultra_processed} onChange={(e) => handleChange('avoid_ultra_processed', e.target.checked)} /> No Ultra-Processed
</label>
<label className="checkbox-label">
<input type="checkbox" checked={user.caffeine_sensitive} onChange={(e) => handleChange('caffeine_sensitive', e.target.checked)} /> Caffeine Sensitive
</label>
</div>
</div>
{/* My Flags */}
<div className="section">
<h2 className="section-title">Custom Flags</h2>
<div className="flag-input-group">
<input
type="text"
value={flagInput}
onChange={(e) => setFlagInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleAddFlag()
}}
className="input-field"
placeholder="Ex: No seed oils..."
/>
<button className="add-flag-btn" onClick={handleAddFlag}>+</button>
</div>
<div className="flag-list">
{user.flags.map((flag, index) => (
<div key={index} className="flag-item">
<input
type="text"
value={flag}
onChange={(e) => handleUpdateFlag(index, e.target.value)}
className="flag-input"
/>
<button
className="flag-delete-btn"
onClick={() => handleDeleteFlag(index)}
>
✕
</button>
</div>
))}
</div>
</div>
{/* SAVE BUTTON */}
<button className="save-btn" onClick={handleSave}>✅ Save Changes</button>
</div>
</div>
</div>
)
}
Necessary CSS (src/components/UserProfile.css)
To make the overlay work correctly, make sure your CSS file has these styles:
/* Dark Overlay Background */
.profile-overlay {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: rgba(0, 0, 0, 0.75);
backdrop-filter: blur(5px);
z-index: 2000; /* High z-index to sit on top of everything */
display: flex;
justify-content: center;
align-items: center;
padding: 20px;
}
/* The White Modal Card */
.profile-container {
background: white;
width: 100%;
max-width: 450px;
max-height: 90vh; /* Don't go taller than screen */
border-radius: 20px;
display: flex;
flex-direction: column;
box-shadow: 0 10px 40px rgba(0,0,0,0.4);
animation: popIn 0.3s cubic-bezier(0.16, 1, 0.3, 1);
}
/* Header Area */
.profile-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 20px 10px 20px;
border-bottom: 1px solid #eee;
}
.profile-title {
font-size: 1.4rem;
margin: 0;
color: #333;
}
.close-icon-btn {
background: #f1f3f5;
border: none;
width: 36px;
height: 36px;
border-radius: 50%;
font-size: 1.2rem;
cursor: pointer;
color: #555;
display: flex;
align-items: center;
justify-content: center;
}
/* Scrollable Content Area */
.profile-content {
overflow-y: auto; /* Allow scrolling inside the card */
padding: 20px;
}
/* ... Keep your existing input/checkbox styles below ... */
.section { margin-bottom: 25px; }
.section-title { font-size: 1rem; color: #666; margin-bottom: 10px; text-transform: uppercase; letter-spacing: 0.5px; font-weight: 700; }
.input-field {
width: 100%;
padding: 12px;
border: 1px solid #ddd;
border-radius: 10px;
font-size: 1rem;
margin-top: 5px;
box-sizing: border-box;
}
.checkbox-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.checkbox-label {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.95rem;
color: #444;
background: #f8f9fa;
padding: 8px;
border-radius: 8px;
}
.flag-input-group { display: flex; gap: 10px; margin-bottom: 10px; }
.add-flag-btn { background: #333; color: white; border: none; width: 40px; border-radius: 8px; font-size: 1.5rem; cursor: pointer; }
.save-btn {
width: 100%;
background: #007bff;
color: white;
padding: 16px;
border-radius: 12px;
border: none;
font-size: 1.1rem;
font-weight: 700;
margin-top: 20px;
cursor: pointer;
}
@keyframes popIn {
from { opacity: 0; transform: scale(0.95); }
to { opacity: 1; transform: scale(1); }
}
not showing anything
If you are "not showing anything" (the screen is blank or the modal doesn't appear), it is likely due to one of two reasons:
The CSS "Transform" Trap: You might have placed the
<UserProfile />inside the<div className="sliding-wrapper">. Because that wrapper usestransform, it breaksposition: fixed, making your modal disappear or appear off-screen.Missing Props: You updated
UserProfile.tsxto require props (onSave,onClose), but yourCameraCapture.tsxisn't passing them yet.
Here is the Fixed src/components/CameraCapture.tsx.
I have placed the <UserProfile /> outside the slider so it sits correctly on top of the screen, and I connected all the new props.
src/components/CameraCapture.tsx
import { useState, type ChangeEvent } from 'react'
import './CameraCapture.css'
import { type RealFoodAnalysis, type APIResponse, type PersonalFitAnalysis, type UserProfile as UserProfileType } from '../types'
import AnalysisResults from './AnalysisResult'
import PersonalFitResult from './PersonalFitResult';
// 👇 Import the component AND the type
import UserProfile, { type UserData } from './UserProfile';
type LensType = 'focus' | 'real_food' | 'personal';
export default function CameraCapture() {
const [image, setImage] = useState<string | null>(null)
// Data States
const [realFoodAnalysis, setRealFoodAnalysis] = useState<RealFoodAnalysis | null>(null)
const [personalFitAnalysis, setPersonalFitAnalysis] = useState<PersonalFitAnalysis | null>(null)
const [audioData, setAudioData] = useState<string | undefined>(undefined)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string>("")
const [selectedLens, setSelectedLens] = useState<LensType>('real_food')
// --- 1. USER PROFILE STATE ---
const [showProfile, setShowProfile] = useState(false);
// Initialize with your default values
const [userProfile, setUserProfile] = useState<UserData>({
goal: 'gain',
diet: 'Halal',
peanut: false,
tree_nut: false,
dairy: false,
gluten: false,
egg: false,
shellfish: false,
sesame: false,
soy: false,
avoid_artificial_colors: false,
avoid_artificial_sweeteners: false,
avoid_ultra_processed: false,
caffeine_sensitive: false,
flags: [],
});
const handleImageChange = async (e: ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files[0]) {
const file = e.target.files[0]
const url = URL.createObjectURL(file)
setImage(url)
setRealFoodAnalysis(null)
setPersonalFitAnalysis(null)
setAudioData(undefined) // Reset audio
setError("")
setLoading(true)
const formData = new FormData()
formData.append("file", file)
formData.append("lens", selectedLens)
// --- 2. SEND PROFILE TO BACKEND ---
if (selectedLens === 'personal') {
formData.append("user_profile", JSON.stringify(userProfile));
}
try {
const response = await fetch("http://172.20.10.3:8000/api/analyze", {
method: "POST",
body: formData,
})
if (!response.ok) throw new Error("Backend failed");
const data: APIResponse = await response.json();
if (data.audio_base64) {
setAudioData(data.audio_base64);
}
let parsedAnalysis;
try {
parsedAnalysis = typeof data.health_analysis === 'string'
? JSON.parse(data.health_analysis)
: data.health_analysis;
} catch (e) {
console.error("JSON Parse Error:", e);
throw new Error("Could not read ingredients format.");
}
if (parsedAnalysis) {
if (selectedLens === 'personal') {
setPersonalFitAnalysis(parsedAnalysis as PersonalFitAnalysis);
} else {
setRealFoodAnalysis(parsedAnalysis as RealFoodAnalysis);
}
}
} catch (err) {
console.error(err);
setError("Error: Could not reach the brain! 🧠❌");
} finally {
setLoading(false);
}
}
}
const hasResults = (realFoodAnalysis !== null || personalFitAnalysis !== null) || loading;
return (
<div className="camera-container">
{/* ⚠️ CRITICAL: Render Profile HERE, OUTSIDE the sliding-wrapper */}
{showProfile && (
<UserProfile
initialData={userProfile}
onClose={() => setShowProfile(false)}
onSave={(updatedData) => {
setUserProfile(updatedData);
setShowProfile(false);
}}
/>
)}
{/* --- PROFILE TOGGLE BUTTON --- */}
{/* Only show if no image taken yet */}
{!image && (
<button
className="profile-toggle-btn"
onClick={() => setShowProfile(true)}
style={{
position: 'absolute', top: '40px', left: '20px', zIndex: 50,
background: 'rgba(0,0,0,0.5)', color: 'white', border: '1px solid #555',
padding: '8px 15px', borderRadius: '20px', cursor: 'pointer'
}}
>
👤 {userProfile.goal.toUpperCase()}
</button>
)}
{/* --- MAIN SLIDER --- */}
<div className={`sliding-wrapper ${hasResults ? 'show-results' : 'show-camera'}`}>
{/* LEFT SIDE: Camera */}
<div className="image-area">
{!image && (
<div className="lens-overlay">
<button
className={`lens-btn ${selectedLens === 'focus' ? 'active' : ''}`}
onClick={() => setSelectedLens('focus')}
>
<span className="lens-icon">🔍</span>
<span className="lens-label">Focus</span>
</button>
<button
className={`lens-btn ${selectedLens === 'real_food' ? 'active' : ''}`}
onClick={() => setSelectedLens('real_food')}
>
<span className="lens-icon">🥗</span>
<span className="lens-label">Real Food</span>
</button>
<button
className={`lens-btn ${selectedLens === 'personal' ? 'active' : ''}`}
onClick={() => setSelectedLens('personal')}
>
<span className="lens-icon">👤</span>
<span className="lens-label">Personal</span>
</button>
</div>
)}
{image ? (
<img src={image} alt="Captured" className="photo-preview" />
) : (
<div className="placeholder">
<div style={{fontSize: '3rem', marginBottom: '10px'}}>📸</div>
<p>Take a photo</p>
</div>
)}
</div>
{/* RIGHT SIDE: Results */}
<div className="results-list">
{selectedLens === 'personal' ? (
<PersonalFitResult
data={personalFitAnalysis as any}
audio={audioData}
loading={loading}
error={error}
/>
) : (
<AnalysisResults
data={realFoodAnalysis as any}
audio={audioData}
loading={loading}
error={error}
/>
)}
</div>
</div>
<div className="controls-area">
<input
type="file"
accept="image/*"
capture="environment"
onChange={handleImageChange}
id="cameraInput"
style={{ display: 'none' }}
/>
<label htmlFor="cameraInput" className="camera-button">
{loading ? "Scanning..." : (hasResults ? "📸 Scan New" : "📸 Take Photo")}
</label>
</div>
</div>
)
}
Checklist to make it appear:
Placement: Ensure
<UserProfile ... />is the first thing inside<div className="camera-container">. It must NOT be inside<div className="sliding-wrapper">.CSS: Ensure
UserProfile.cssexists and has.profile-overlay { position: fixed; z-index: 2000; ... }.State: The button in the top left (
onClick={() => setShowProfile(true)}) triggers the state change.
I want the user profile to be in the sidebar button not outside like this
This is a great UX improvement. Instead of a floating "pill" button cluttering the camera view, we will move the User Profile into a clean, slide-out Sidebar Menu.
We need to:
Create a Sidebar Component.
Add a Menu Button (Hamburger) to
CameraCapture.Move the Profile Summary inside that Sidebar.
Step 1: Create src/components/Sidebar.css
This handles the sliding animation and the look of the menu.
/* src/components/Sidebar.css */
/* Dark Backdrop (Click to close) */
.sidebar-backdrop {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: rgba(0, 0, 0, 0.5);
z-index: 1500; /* Below the drawer, above the app */
opacity: 0;
pointer-events: none;
transition: opacity 0.3s ease;
}
.sidebar-backdrop.open {
opacity: 1;
pointer-events: auto;
}
/* The Sliding Drawer */
.sidebar-drawer {
position: fixed;
top: 0;
left: 0;
width: 75%; /* Covers 3/4 of mobile screen */
max-width: 300px;
height: 100vh;
background: #fff;
z-index: 1600; /* Top level */
transform: translateX(-100%); /* Hidden by default */
transition: transform 0.3s cubic-bezier(0.25, 1, 0.5, 1);
box-shadow: 4px 0 15px rgba(0,0,0,0.1);
display: flex;
flex-direction: column;
}
.sidebar-drawer.open {
transform: translateX(0);
}
/* Header */
.sidebar-header {
padding: 25px 20px;
background: #f8f9fa;
border-bottom: 1px solid #eee;
}
.sidebar-title {
margin: 0;
font-size: 1.2rem;
font-weight: 800;
color: #333;
}
/* Content */
.sidebar-content {
padding: 20px;
flex: 1;
}
/* Profile Item Card */
.sidebar-profile-card {
display: flex;
align-items: center;
gap: 15px;
background: #fff;
padding: 15px;
border-radius: 12px;
border: 1px solid #eee;
box-shadow: 0 2px 5px rgba(0,0,0,0.05);
cursor: pointer;
transition: transform 0.1s, background 0.1s;
}
.sidebar-profile-card:active {
transform: scale(0.98);
background: #f9f9f9;
}
.sidebar-avatar {
font-size: 1.8rem;
background: #eef2ff;
width: 50px;
height: 50px;
border-radius: 50%;
display: flex;
justify-content: center;
align-items: center;
}
.sidebar-user-info {
display: flex;
flex-direction: column;
}
.sidebar-goal {
font-weight: 700;
color: #333;
font-size: 1rem;
}
.sidebar-diet {
font-size: 0.85rem;
color: #888;
}
.edit-icon {
margin-left: auto;
color: #ccc;
}
Step 2: Create src/components/Sidebar.tsx
This component displays the menu and the profile summary card.
// src/components/Sidebar.tsx
import './Sidebar.css';
import { UserData } from './UserProfile';
interface Props {
isOpen: boolean;
onClose: () => void;
userProfile: UserData;
onOpenProfile: () => void; // Trigger to open the full editor
}
export default function Sidebar({ isOpen, onClose, userProfile, onOpenProfile }: Props) {
return (
<>
{/* 1. BACKDROP (Click to close) */}
<div
className={`sidebar-backdrop ${isOpen ? 'open' : ''}`}
onClick={onClose}
/>
{/* 2. SLIDING DRAWER */}
<div className={`sidebar-drawer ${isOpen ? 'open' : ''}`}>
<div className="sidebar-header">
<h2 className="sidebar-title">Menu</h2>
</div>
<div className="sidebar-content">
<p style={{fontSize: '0.85rem', color: '#888', marginBottom: '10px', textTransform: 'uppercase', letterSpacing: '1px', fontWeight: 'bold'}}>Profile</p>
{/* CLICKABLE PROFILE CARD */}
<div
className="sidebar-profile-card"
onClick={() => {
onOpenProfile(); // Open the editor
onClose(); // Close the sidebar
}}
>
<div className="sidebar-avatar">👤</div>
<div className="sidebar-user-info">
<span className="sidebar-goal">{userProfile.goal === 'gain' ? 'Gain Muscle' : 'Lose Weight'}</span>
<span className="sidebar-diet">{userProfile.diet}</span>
</div>
<div className="edit-icon">✎</div>
</div>
{/* You can add more menu items here later (Settings, History, etc.) */}
</div>
</div>
</>
);
}
Step 3: Update src/components/CameraCapture.tsx
We will replace the floating "pill" button with a Hamburger Menu Button and add the Sidebar logic.
// src/components/CameraCapture.tsx
import { useState, type ChangeEvent } from 'react'
import './CameraCapture.css'
import { type RealFoodAnalysis, type APIResponse, type PersonalFitAnalysis, type UserProfile as UserProfileType } from '../types'
import AnalysisResults from './AnalysisResult'
import PersonalFitResult from './PersonalFitResult';
import UserProfile, { type UserData } from './UserProfile';
import Sidebar from './Sidebar'; // <--- IMPORT SIDEBAR
type LensType = 'focus' | 'real_food' | 'personal';
export default function CameraCapture() {
const [image, setImage] = useState<string | null>(null)
// Data States
const [realFoodAnalysis, setRealFoodAnalysis] = useState<RealFoodAnalysis | null>(null)
const [personalFitAnalysis, setPersonalFitAnalysis] = useState<PersonalFitAnalysis | null>(null)
const [audioData, setAudioData] = useState<string | undefined>(undefined)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string>("")
const [selectedLens, setSelectedLens] = useState<LensType>('real_food')
// --- UI STATES ---
const [showProfileModal, setShowProfileModal] = useState(false); // For the full editor
const [isSidebarOpen, setSidebarOpen] = useState(false); // For the menu
// Profile Data
const [userProfile, setUserProfile] = useState<UserData>({
goal: 'gain',
diet: 'Halal',
peanut: false, tree_nut: false, dairy: false, gluten: false,
egg: false, shellfish: false, sesame: false, soy: false,
avoid_artificial_colors: false, avoid_artificial_sweeteners: false,
avoid_ultra_processed: false, caffeine_sensitive: false,
flags: [],
});
const handleImageChange = async (e: ChangeEvent<HTMLInputElement>) => {
// ... (Keep your existing image handling code EXACTLY the same) ...
// ... Copy-paste the logic from your previous file here ...
// ... Just hiding it for brevity in this response ...
if (e.target.files && e.target.files[0]) {
const file = e.target.files[0]
const url = URL.createObjectURL(file)
setImage(url)
setRealFoodAnalysis(null)
setPersonalFitAnalysis(null)
setAudioData(undefined)
setError("")
setLoading(true)
const formData = new FormData()
formData.append("file", file)
formData.append("lens", selectedLens)
if (selectedLens === 'personal') {
formData.append("user_profile", JSON.stringify(userProfile));
}
try {
const response = await fetch("http://172.20.10.3:8000/api/analyze", {
method: "POST",
body: formData,
})
if (!response.ok) throw new Error("Backend failed");
const data: APIResponse = await response.json();
if (data.audio_base64) setAudioData(data.audio_base64);
let parsedAnalysis;
try {
parsedAnalysis = typeof data.health_analysis === 'string'
? JSON.parse(data.health_analysis)
: data.health_analysis;
} catch (e) {
throw new Error("Could not read ingredients format.");
}
if (parsedAnalysis) {
if (selectedLens === 'personal') {
setPersonalFitAnalysis(parsedAnalysis as PersonalFitAnalysis);
} else {
setRealFoodAnalysis(parsedAnalysis as RealFoodAnalysis);
}
}
} catch (err) {
setError("Error: Could not reach the brain! 🧠❌");
} finally {
setLoading(false);
}
}
}
const hasResults = (realFoodAnalysis !== null || personalFitAnalysis !== null) || loading;
return (
<div className="camera-container">
{/* 1. SIDEBAR COMPONENT */}
<Sidebar
isOpen={isSidebarOpen}
onClose={() => setSidebarOpen(false)}
userProfile={userProfile}
onOpenProfile={() => setShowProfileModal(true)}
/>
{/* 2. USER PROFILE MODAL (Hidden unless clicked in Sidebar) */}
{showProfileModal && (
<UserProfile
initialData={userProfile}
onClose={() => setShowProfileModal(false)}
onSave={(updatedData) => {
setUserProfile(updatedData);
setShowProfileModal(false);
}}
/>
)}
{/* 3. MENU BUTTON (The Hamburger) */}
{/* Only show if we haven't taken a picture yet, to keep camera clean */}
{!image && (
<button
className="menu-btn"
onClick={() => setSidebarOpen(true)}
>
{/* Simple CSS Hamburger Icon */}
<div className="bar"></div>
<div className="bar"></div>
<div className="bar"></div>
</button>
)}
{/* --- MAIN SLIDER --- */}
<div className={`sliding-wrapper ${hasResults ? 'show-results' : 'show-camera'}`}>
{/* LEFT SIDE: Camera */}
<div className="image-area">
{!image && (
<div className="lens-overlay">
<button
className={`lens-btn ${selectedLens === 'focus' ? 'active' : ''}`}
onClick={() => setSelectedLens('focus')}
>
<span className="lens-icon">🔍</span>
<span className="lens-label">Focus</span>
</button>
<button
className={`lens-btn ${selectedLens === 'real_food' ? 'active' : ''}`}
onClick={() => setSelectedLens('real_food')}
>
<span className="lens-icon">🥗</span>
<span className="lens-label">Real Food</span>
</button>
<button
className={`lens-btn ${selectedLens === 'personal' ? 'active' : ''}`}
onClick={() => setSelectedLens('personal')}
>
<span className="lens-icon">👤</span>
<span className="lens-label">Personal</span>
</button>
</div>
)}
{image ? (
<img src={image} alt="Captured" className="photo-preview" />
) : (
<div className="placeholder">
<div style={{fontSize: '3rem', marginBottom: '10px'}}>📸</div>
<p>Take a photo</p>
</div>
)}
</div>
{/* RIGHT SIDE: Results */}
<div className="results-list">
{selectedLens === 'personal' ? (
<PersonalFitResult
data={personalFitAnalysis as any}
audio={audioData}
loading={loading}
error={error}
/>
) : (
<AnalysisResults
data={realFoodAnalysis as any}
audio={audioData}
loading={loading}
error={error}
/>
)}
</div>
</div>
<div className="controls-area">
<input
type="file"
accept="image/*"
capture="environment"
onChange={handleImageChange}
id="cameraInput"
style={{ display: 'none' }}
/>
<label htmlFor="cameraInput" className="camera-button">
{loading ? "Scanning..." : (hasResults ? "📸 Scan New" : "📸 Take Photo")}
</label>
</div>
</div>
)
}
Step 4: Add Hamburger Button Styles (CameraCapture.css)
Add this to make the menu button look good in the top left corner.
/* src/components/CameraCapture.css */
.menu-btn {
position: absolute;
top: 40px;
left: 20px;
width: 45px;
height: 45px;
background: rgba(0, 0, 0, 0.4); /* Transparent dark background */
backdrop-filter: blur(5px);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 50%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 4px;
cursor: pointer;
z-index: 50;
transition: background 0.2s;
}
.menu-btn:active {
background: rgba(0, 0, 0, 0.6);
transform: scale(0.95);
}
.menu-btn .bar {
width: 20px;
height: 2px;
background: white;
border-radius: 2px;
}
// src/components/Sidebar.tsx
import './Sidebar.css'
import { type UserData } from './UserProfile'
interface SidebarProps {
isOpen: boolean
onClose: () => void
onNavigate: (page: 'camera' | 'profile') => void
userProfile: UserData
activePage: 'camera' | 'profile'
}
export default function Sidebar({ isOpen, onClose, onNavigate, userProfile, activePage }: SidebarProps) {
const handleNav = (page: 'camera' | 'profile') => {
onNavigate(page)
onClose() // Close sidebar after clicking
}
return (
<>
{/* 1. BACKDROP (Darkens the background when menu is open) */}
<div
className={`sidebar-backdrop ${isOpen ? 'open' : ''}`}
onClick={onClose}
/>
{/* 2. SIDEBAR PANEL */}
<div className={`sidebar-panel ${isOpen ? 'open' : ''}`}>
<div className="sidebar-header">
<h2>Menu</h2>
<button onClick={onClose} className="close-btn">✕</button>
</div>
<nav className="sidebar-nav">
<button
className={`nav-item ${activePage === 'camera' ? 'active' : ''}`}
onClick={() => handleNav('camera')}
>
📸 Scan Food
</button>
<button
className={`nav-item ${activePage === 'profile' ? 'active' : ''}`}
onClick={() => handleNav('profile')}
>
👤 My Profile
</button>
</nav>
</div>
</>
)
}
Change based on this
This is a great, clean implementation of a Sidebar!
To make this work, we need to:
Create the CSS to make it slide in and look good.
Update
CameraCapture.tsxto handle theactivePagestate ('camera' vs 'profile') and render this Sidebar.
Here is the complete setup.
1. Create src/components/Sidebar.css
This matches the class names in your code (sidebar-backdrop, sidebar-panel, etc.) and adds the slide animation.
/* src/components/Sidebar.css */
/* 1. BACKDROP */
.sidebar-backdrop {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: rgba(0, 0, 0, 0.5); /* Semi-transparent black */
backdrop-filter: blur(3px); /* Blur the content behind */
z-index: 1500;
opacity: 0;
pointer-events: none; /* Let clicks pass through when hidden */
transition: opacity 0.3s ease;
}
.sidebar-backdrop.open {
opacity: 1;
pointer-events: auto; /* Catch clicks when open */
}
/* 2. SLIDING PANEL */
.sidebar-panel {
position: fixed;
top: 0;
left: 0;
width: 280px; /* Width of the menu */
height: 100vh;
background: white;
z-index: 1600; /* Above the backdrop */
box-shadow: 4px 0 15px rgba(0,0,0,0.1);
transform: translateX(-100%); /* Hidden to the left */
transition: transform 0.3s cubic-bezier(0.25, 1, 0.5, 1);
display: flex;
flex-direction: column;
}
.sidebar-panel.open {
transform: translateX(0); /* Slide in */
}
/* 3. HEADER */
.sidebar-header {
padding: 20px;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid #eee;
}
.sidebar-header h2 {
margin: 0;
font-size: 1.2rem;
color: #333;
}
.close-btn {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: #666;
padding: 5px;
}
/* 4. NAVIGATION LINKS */
.sidebar-nav {
padding: 20px;
display: flex;
flex-direction: column;
gap: 10px;
}
.nav-item {
background: none;
border: none;
text-align: left;
font-size: 1.1rem;
padding: 12px 15px;
border-radius: 12px;
color: #555;
cursor: pointer;
transition: background 0.2s, color 0.2s;
font-weight: 500;
}
.nav-item:hover {
background: #f8f9fa;
}
.nav-item.active {
background: #eef2ff;
color: #4f46e5; /* Indigo/Purple brand color */
font-weight: 700;
}
2. Update src/components/CameraCapture.tsx
I have updated the main file to:
Import your new
Sidebar.Add state for
activePage('camera' or 'profile').Add the Hamburger Menu button.
Conditionally render the Camera UI vs. the Profile UI based on the sidebar selection.
// src/components/CameraCapture.tsx
import { useState, type ChangeEvent } from 'react'
import './CameraCapture.css'
import { type RealFoodAnalysis, type APIResponse, type PersonalFitAnalysis, type UserProfile as UserProfileType } from '../types'
import AnalysisResults from './AnalysisResult'
import PersonalFitResult from './PersonalFitResult';
// Import your components
import UserProfile, { type UserData } from './UserProfile';
import Sidebar from './Sidebar'; // <--- NEW IMPORT
type LensType = 'focus' | 'real_food' | 'personal';
type PageType = 'camera' | 'profile'; // <--- NEW TYPE
export default function CameraCapture() {
// ... (Keep existing image/data/loading states exactly the same) ...
const [image, setImage] = useState<string | null>(null)
const [realFoodAnalysis, setRealFoodAnalysis] = useState<RealFoodAnalysis | null>(null)
const [personalFitAnalysis, setPersonalFitAnalysis] = useState<PersonalFitAnalysis | null>(null)
const [audioData, setAudioData] = useState<string | undefined>(undefined)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string>("")
const [selectedLens, setSelectedLens] = useState<LensType>('real_food')
// --- NAVIGATION STATE ---
const [isSidebarOpen, setSidebarOpen] = useState(false);
const [activePage, setActivePage] = useState<PageType>('camera');
// --- USER PROFILE STATE ---
const [userProfile, setUserProfile] = useState<UserData>({
goal: 'gain',
diet: 'Halal',
peanut: false, tree_nut: false, dairy: false, gluten: false,
egg: false, shellfish: false, sesame: false, soy: false,
avoid_artificial_colors: false, avoid_artificial_sweeteners: false,
avoid_ultra_processed: false, caffeine_sensitive: false,
flags: [],
});
const handleImageChange = async (e: ChangeEvent<HTMLInputElement>) => {
// ... (Keep your existing fetch logic EXACTLY the same) ...
if (e.target.files && e.target.files[0]) {
const file = e.target.files[0]
const url = URL.createObjectURL(file)
setImage(url)
setRealFoodAnalysis(null)
setPersonalFitAnalysis(null)
setAudioData(undefined)
setError("")
setLoading(true)
const formData = new FormData()
formData.append("file", file)
formData.append("lens", selectedLens)
if (selectedLens === 'personal') {
formData.append("user_profile", JSON.stringify(userProfile));
}
try {
const response = await fetch("http://172.20.10.3:8000/api/analyze", {
method: "POST",
body: formData,
})
if (!response.ok) throw new Error("Backend failed");
const data: APIResponse = await response.json();
if (data.audio_base64) setAudioData(data.audio_base64);
let parsedAnalysis;
try {
parsedAnalysis = typeof data.health_analysis === 'string'
? JSON.parse(data.health_analysis)
: data.health_analysis;
} catch (e) {
throw new Error("Could not read ingredients format.");
}
if (parsedAnalysis) {
if (selectedLens === 'personal') {
setPersonalFitAnalysis(parsedAnalysis as PersonalFitAnalysis);
} else {
setRealFoodAnalysis(parsedAnalysis as RealFoodAnalysis);
}
}
} catch (err) {
setError("Error: Could not reach the brain! 🧠❌");
} finally {
setLoading(false);
}
}
}
const hasResults = (realFoodAnalysis !== null || personalFitAnalysis !== null) || loading;
return (
<div className="camera-container">
{/* 1. SIDEBAR COMPONENT */}
<Sidebar
isOpen={isSidebarOpen}
onClose={() => setSidebarOpen(false)}
onNavigate={(page) => setActivePage(page)}
userProfile={userProfile}
activePage={activePage}
/>
{/* 2. HAMBURGER BUTTON (Visible on 'camera' page) */}
{/* If activePage is 'profile', the sidebar is accessible via back button or menu inside profile, but usually we keep menu btn */}
<button
className="menu-btn"
onClick={() => setSidebarOpen(true)}
style={{
position: 'absolute', top: '20px', left: '20px', zIndex: 100,
background: 'rgba(255,255,255,0.2)', backdropFilter: 'blur(5px)',
border: 'none', borderRadius: '50%', width: '45px', height: '45px',
fontSize: '1.5rem', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center'
}}
>
☰
</button>
{/* 3. PAGE CONTENT SWITCHER */}
{/* A. PROFILE PAGE MODE */}
{activePage === 'profile' && (
<div style={{ paddingTop: '80px', height: '100vh', background: '#f8f9fa', overflowY: 'auto' }}>
<UserProfile
initialData={userProfile}
onClose={() => setActivePage('camera')} // 'Close' goes back to camera
onSave={(updatedData) => {
setUserProfile(updatedData);
setActivePage('camera'); // Go back to camera after saving
}}
/>
</div>
)}
{/* B. CAMERA PAGE MODE */}
{activePage === 'camera' && (
<>
{/* The Main Slider (Camera + Results) */}
<div className={`sliding-wrapper ${hasResults ? 'show-results' : 'show-camera'}`}>
{/* Left: Camera */}
<div className="image-area">
{/* Lens Overlay */}
{!image && (
<div className="lens-overlay">
{/* ... (Your existing lens buttons) ... */}
<button className={`lens-btn ${selectedLens === 'focus' ? 'active' : ''}`} onClick={() => setSelectedLens('focus')}><span className="lens-icon">🔍</span>Focus</button>
<button className={`lens-btn ${selectedLens === 'real_food' ? 'active' : ''}`} onClick={() => setSelectedLens('real_food')}><span className="lens-icon">🥗</span>Real Food</button>
<button className={`lens-btn ${selectedLens === 'personal' ? 'active' : ''}`} onClick={() => setSelectedLens('personal')}><span className="lens-icon">👤</span>Personal</button>
</div>
)}
{image ? (
<img src={image} alt="Captured" className="photo-preview" />
) : (
<div className="placeholder">
<div style={{fontSize: '3rem', marginBottom: '10px'}}>📸</div>
<p>Take a photo</p>
</div>
)}
</div>
{/* Right: Results */}
<div className="results-list">
{selectedLens === 'personal' ? (
<PersonalFitResult data={personalFitAnalysis as any} audio={audioData} loading={loading} error={error} />
) : (
<AnalysisResults data={realFoodAnalysis as any} audio={audioData} loading={loading} error={error} />
)}
</div>
</div>
{/* Bottom Controls */}
<div className="controls-area">
<input type="file" accept="image/*" capture="environment" onChange={handleImageChange} id="cameraInput" style={{ display: 'none' }} />
<label htmlFor="cameraInput" className="camera-button">
{loading ? "Scanning..." : (hasResults ? "📸 Scan New" : "📸 Take Photo")}
</label>
</div>
</>
)}
</div>
)
}
i mean change the sidebar according to you to update UserProfile
.jpg)
Gemini is AI and can make mistakes.
